Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee01f9621 | |||
| 3ebdbfec81 | |||
| 1e61042bf6 |
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.208",
|
"@ai-sdk/react": "^3.0.208",
|
||||||
"@braintree/sanitize-url": "7.1.2",
|
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||||
|
|||||||
+24
-58
@@ -1,72 +1,38 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { Center, Loader } from "@mantine/core";
|
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
|
|
||||||
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
|
||||||
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
|
||||||
// editor engine from startup for authenticated users too.
|
|
||||||
const ShareLayout = lazy(
|
|
||||||
() => import("@/features/share/components/share-layout.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
|
||||||
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
|
||||||
// add a cold-chunk round trip to the most common cold-start path.
|
|
||||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||||
import LoginPage from "@/pages/auth/login";
|
import LoginPage from "@/pages/auth/login";
|
||||||
|
import Home from "@/pages/dashboard/home";
|
||||||
|
import Page from "@/pages/page/page";
|
||||||
|
import AccountSettings from "@/pages/settings/account/account-settings";
|
||||||
|
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
||||||
|
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
||||||
|
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
||||||
|
import Groups from "@/pages/settings/group/groups";
|
||||||
|
import GroupInfo from "./pages/settings/group/group-info";
|
||||||
|
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
||||||
|
import SpaceHome from "@/pages/space/space-home.tsx";
|
||||||
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||||
|
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||||
|
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||||
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||||
// the page editor and the readonly share editor pull in) is fetched only when
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
// the matching route is actually visited. The <Suspense> boundaries live inside
|
import LabelPage from "@/pages/label/label-page";
|
||||||
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
|
||||||
// route chunk loads.
|
|
||||||
const Home = lazy(() => import("@/pages/dashboard/home"));
|
|
||||||
const Page = lazy(() => import("@/pages/page/page"));
|
|
||||||
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
|
||||||
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
|
||||||
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
|
||||||
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
|
||||||
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
|
||||||
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
|
||||||
|
|
||||||
const AccountSettings = lazy(
|
|
||||||
() => import("@/pages/settings/account/account-settings"),
|
|
||||||
);
|
|
||||||
const AccountPreferences = lazy(
|
|
||||||
() => import("@/pages/settings/account/account-preferences.tsx"),
|
|
||||||
);
|
|
||||||
const WorkspaceSettings = lazy(
|
|
||||||
() => import("@/pages/settings/workspace/workspace-settings"),
|
|
||||||
);
|
|
||||||
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
|
||||||
const WorkspaceMembers = lazy(
|
|
||||||
() => import("@/pages/settings/workspace/workspace-members"),
|
|
||||||
);
|
|
||||||
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
|
||||||
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
|
||||||
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
|
||||||
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<>
|
||||||
fallback={
|
|
||||||
<Center h="100vh">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
@@ -117,6 +83,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
|
||||||
|
|
||||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
|
||||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
|
||||||
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
|
||||||
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
|
||||||
describe("isChunkLoadError", () => {
|
|
||||||
it("detects the ChunkLoadError name", () => {
|
|
||||||
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
|
||||||
"error loading dynamically imported module",
|
|
||||||
"Importing a module script failed.",
|
|
||||||
])("detects the dynamic-import failure message %#", (message) => {
|
|
||||||
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-insensitive on the message", () => {
|
|
||||||
expect(
|
|
||||||
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
null,
|
|
||||||
undefined,
|
|
||||||
{},
|
|
||||||
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
|
||||||
{ message: "Network request failed" },
|
|
||||||
new Error("some ordinary render error"),
|
|
||||||
])("returns false for a non-chunk error %#", (err) => {
|
|
||||||
expect(isChunkLoadError(err)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
|
||||||
|
|
||||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
|
||||||
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
|
||||||
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
|
||||||
// with a ChunkLoadError name or one of these messages.
|
|
||||||
export function isChunkLoadError(error: unknown): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
const name = (error as { name?: string }).name ?? "";
|
|
||||||
const message = (error as { message?: string }).message ?? "";
|
|
||||||
return (
|
|
||||||
name === "ChunkLoadError" ||
|
|
||||||
/Failed to fetch dynamically imported module/i.test(message) ||
|
|
||||||
/error loading dynamically imported module/i.test(message) ||
|
|
||||||
/Importing a module script failed/i.test(message)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleError(error: unknown) {
|
|
||||||
if (!isChunkLoadError(error)) return;
|
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
|
||||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
|
||||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
|
||||||
// flag is already set we fall through to the manual recovery UI below.
|
|
||||||
try {
|
|
||||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
|
||||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
|
||||||
} catch {
|
|
||||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
|
||||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
|
||||||
// lazy route/component chunk failure is caught here instead of unmounting the
|
|
||||||
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
|
||||||
// transclusion, page-embed) remain in place underneath for their local errors.
|
|
||||||
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary
|
|
||||||
onError={handleError}
|
|
||||||
fallbackRender={({ error }) => {
|
|
||||||
const chunk = isChunkLoadError(error);
|
|
||||||
return (
|
|
||||||
<Center h="100vh" p="md">
|
|
||||||
<Stack align="center" gap="sm" maw={420}>
|
|
||||||
<Text fw={600}>
|
|
||||||
{chunk ? "A new version is available" : "Something went wrong"}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{chunk
|
|
||||||
? "Please reload the page to load the latest version."
|
|
||||||
: "An unexpected error occurred. Reloading the page may help."}
|
|
||||||
</Text>
|
|
||||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||||
@@ -15,6 +14,8 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
|
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
||||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
@@ -22,21 +23,6 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
|||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||||
|
|
||||||
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
|
||||||
// only after the user first opens the chat, instead of for every authenticated
|
|
||||||
// user on load. The window itself renders null while closed, so there is no
|
|
||||||
// behavior difference — it simply is not mounted until first opened.
|
|
||||||
const AiChatWindow = React.lazy(
|
|
||||||
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The right aside hosts the comment panel and table of contents, both of which
|
|
||||||
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
|
||||||
// the whole editor engine out of the eager global-shell startup graph.
|
|
||||||
const Aside = React.lazy(
|
|
||||||
() => import("@/components/layouts/global/aside.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -51,15 +37,6 @@ export default function GlobalAppShell({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
// Latch: once the AI chat window has been opened, keep it mounted so an
|
|
||||||
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
|
||||||
// is never fetched.
|
|
||||||
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
|
||||||
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (aiChatOpen) setAiChatEverOpened(true);
|
|
||||||
}, [aiChatOpen]);
|
|
||||||
|
|
||||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -183,21 +160,13 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Aside />
|
||||||
<Aside />
|
|
||||||
</Suspense>
|
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally on first open; it is
|
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
||||||
position: fixed and self-hides when closed, so its place in the tree is
|
and self-hides when closed, so its place in the tree is not critical. */}
|
||||||
not critical. Kept mounted after the first open so a live stream is not
|
<AiChatWindow />
|
||||||
aborted. */}
|
|
||||||
{aiChatEverOpened && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AiChatWindow />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
createPageWithRecording on window.gitmost so the native host can
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
create a page with a recording even when no page editor is open. */}
|
create a page with a recording even when no page editor is open. */}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Suspense, useEffect } from "react";
|
|
||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
import { Center, Loader } from "@mantine/core";
|
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -10,39 +8,10 @@ export default function Layout() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
// Warm the (now route-split) editor chunk during idle time on authenticated
|
|
||||||
// routes, so the first navigation to a page renders from cache instead of a
|
|
||||||
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
|
||||||
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
|
||||||
// resolve to the same chunk.
|
|
||||||
useEffect(() => {
|
|
||||||
const ric =
|
|
||||||
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
|
||||||
const warm = () => {
|
|
||||||
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
|
||||||
// and must not surface as an unhandledrejection.
|
|
||||||
void import("@/pages/page/page").catch(() => {});
|
|
||||||
};
|
|
||||||
if (ric) {
|
|
||||||
const id = ric(warm);
|
|
||||||
return () => (window as any).cancelIdleCallback?.(id);
|
|
||||||
}
|
|
||||||
const timer = setTimeout(warm, 2000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Suspense
|
<Outlet />
|
||||||
fallback={
|
|
||||||
<Center h="60vh">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</Suspense>
|
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
// Type-only: these atoms only hold an Editor reference for typing. A value
|
import { Editor } from "@tiptap/core";
|
||||||
// import would drag the whole @tiptap/core engine into the eager graph of every
|
|
||||||
// shell component that reads one of these atoms.
|
|
||||||
import type { Editor } from "@tiptap/core";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
|
||||||
|
|
||||||
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
|
||||||
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
|
||||||
const DrawioMenu = lazy(
|
|
||||||
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DrawioMenu {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { NodeViewProps } from "@tiptap/react";
|
|
||||||
|
|
||||||
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
|
||||||
// split into its own chunk and fetched only when a drawio diagram is actually
|
|
||||||
// rendered (mirrors excalidraw-view-lazy).
|
|
||||||
const DrawioView = lazy(
|
|
||||||
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function DrawioViewLazy(props: NodeViewProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DrawioView {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { NodeViewProps } from "@tiptap/react";
|
|
||||||
|
|
||||||
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
|
||||||
// only when a document actually contains a math node (mirrors the mermaid/
|
|
||||||
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
|
||||||
// crashing or blocking the whole editor: while it loads we render the raw
|
|
||||||
// LaTeX source as a node-sized placeholder.
|
|
||||||
const MathBlockView = lazy(
|
|
||||||
() => import("@/features/editor/components/math/math-block.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function MathBlockViewLazy(props: NodeViewProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
|
||||||
<MathBlockView {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { lazy, Suspense } from "react";
|
|
||||||
import { NodeViewProps } from "@tiptap/react";
|
|
||||||
|
|
||||||
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
|
||||||
// only when a document actually contains a math node (mirrors the mermaid/
|
|
||||||
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
|
||||||
// crashing or blocking the whole editor: while it loads we render the raw
|
|
||||||
// LaTeX source as a node-sized placeholder.
|
|
||||||
const MathInlineView = lazy(
|
|
||||||
() => import("@/features/editor/components/math/math-inline.tsx"),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function MathInlineViewLazy(props: NodeViewProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
|
||||||
<MathInlineView {...props} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -81,8 +81,8 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
|||||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
import { Editor } from "@tiptap/core";
|
||||||
// so this always-mounted global bridge does not statically import
|
|
||||||
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
|
||||||
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
|
||||||
// already stores these raw status strings.
|
|
||||||
const YJS_STATUS_CONNECTED = "connected";
|
|
||||||
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
|
||||||
// engine) out of the eager global-shell graph — the bridge only uses it for
|
|
||||||
// annotations/casts, never as a runtime value.
|
|
||||||
import type { Editor } from "@tiptap/core";
|
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -25,19 +16,16 @@ import {
|
|||||||
getSidebarPages,
|
getSidebarPages,
|
||||||
} from "@/features/page/services/page-service.ts";
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
// Types are erased at build time, so importing them does not pull the module's
|
import {
|
||||||
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
|
||||||
// helpers are dynamically imported at call time inside createPageWithRecording,
|
|
||||||
// keeping the editor engine out of the eager global-shell startup graph — the
|
|
||||||
// bridge is mounted for every authenticated user but recording is a rare,
|
|
||||||
// native-host-driven action.
|
|
||||||
import type {
|
|
||||||
GitmostBridge,
|
GitmostBridge,
|
||||||
GitmostCreatePagePayload,
|
GitmostCreatePagePayload,
|
||||||
GitmostCreatePageResult,
|
GitmostCreatePageResult,
|
||||||
GitmostListPagesPayload,
|
GitmostListPagesPayload,
|
||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
@@ -70,7 +58,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === YJS_STATUS_CONNECTED;
|
yjsStatus === WebSocketStatus.Connected;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
return;
|
||||||
@@ -184,15 +172,6 @@ export default function GitmostGlobalBridge() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the recording helpers on demand (see the import note above). This
|
|
||||||
// is the only place they are needed, so the @tiptap/editor-ext code they
|
|
||||||
// pull in stays out of the eager startup graph.
|
|
||||||
const {
|
|
||||||
gitmostDecodePayloadToFile,
|
|
||||||
gitmostUploadFileToEditor,
|
|
||||||
gitmostInsertTranscriptIntoEditor,
|
|
||||||
} = await import("@/features/editor/gitmost/gitmost-recording.ts");
|
|
||||||
|
|
||||||
// Validate/decode the recording BEFORE creating the page so a bad
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
// payload never leaves an empty junk page behind. Per the createPage
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
// error contract, any decode failure collapses to "insert-failed" (the
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Center, Loader } from "@mantine/core";
|
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||||
|
|
||||||
export default function ShareLayout() {
|
export default function ShareLayout() {
|
||||||
return (
|
return (
|
||||||
<ShareShell>
|
<ShareShell>
|
||||||
<Suspense
|
<Outlet />
|
||||||
fallback={
|
|
||||||
<Center h="60vh">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</Suspense>
|
|
||||||
</ShareShell>
|
</ShareShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||||
// polyfilled to support execCommand fallback
|
// polyfilled to support execCommand fallback
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
import { execCommandCopy } from "@docmost/editor-ext";
|
||||||
|
|
||||||
export type UseClipboardOptions = {
|
export type UseClipboardOptions = {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
// Client-local execCommand copy fallback (previously imported from
|
|
||||||
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
|
||||||
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
|
||||||
// engine — through the eager startup graph. Behavior is identical to the
|
|
||||||
// editor-ext helper it replaces.
|
|
||||||
export function execCommandCopy(text: string): void {
|
|
||||||
const textarea = document.createElement("textarea");
|
|
||||||
textarea.value = text;
|
|
||||||
textarea.style.position = "fixed";
|
|
||||||
textarea.style.left = "-9999px";
|
|
||||||
textarea.style.top = "-9999px";
|
|
||||||
document.body.appendChild(textarea);
|
|
||||||
textarea.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { sanitizeUrl } from "./sanitize-url";
|
|
||||||
|
|
||||||
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
|
||||||
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
|
||||||
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
|
||||||
// test so the extracted copy keeps the same guarantees.
|
|
||||||
describe("sanitizeUrl", () => {
|
|
||||||
it("blocks dangerous schemes (returns empty string)", () => {
|
|
||||||
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
|
||||||
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
|
||||||
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
|
||||||
// Case / whitespace obfuscation must not slip past the sanitizer.
|
|
||||||
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for empty / undefined input", () => {
|
|
||||||
expect(sanitizeUrl(undefined)).toBe("");
|
|
||||||
expect(sanitizeUrl("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows safe https, relative file and mailto URLs", () => {
|
|
||||||
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
|
||||||
/^https:\/\/example\.com\/page/,
|
|
||||||
);
|
|
||||||
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
|
||||||
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
|
||||||
"mailto:user@example.com",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
|
||||||
|
|
||||||
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
|
||||||
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
|
||||||
// via the app-wide config module (getFileUrl). This keeps the exact same
|
|
||||||
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
|
||||||
// dependency.
|
|
||||||
export function sanitizeUrl(url: string | undefined): string {
|
|
||||||
if (!url) return "";
|
|
||||||
|
|
||||||
const sanitized = braintreeSanitizeUrl(url);
|
|
||||||
|
|
||||||
// Return an empty string instead of "about:blank".
|
|
||||||
return sanitized === "about:blank" ? "" : sanitized;
|
|
||||||
}
|
|
||||||
+27
-60
@@ -13,14 +13,15 @@ import { ModalsProvider } from "@mantine/modals";
|
|||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import {
|
import {
|
||||||
getPostHogHost,
|
getPostHogHost,
|
||||||
getPostHogKey,
|
getPostHogKey,
|
||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
|
import posthog from "posthog-js";
|
||||||
import { initVitals } from "@/lib/telemetry/vitals";
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
@@ -34,6 +35,15 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isCloud() && isPostHogEnabled) {
|
||||||
|
posthog.init(getPostHogKey(), {
|
||||||
|
api_host: getPostHogHost(),
|
||||||
|
defaults: "2025-05-24",
|
||||||
|
disable_session_recording: true,
|
||||||
|
capture_pageleave: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||||
// subscribing to any observer; non-sampled sessions send nothing.
|
// subscribing to any observer; non-sampled sessions send nothing.
|
||||||
initVitals();
|
initVitals();
|
||||||
@@ -41,62 +51,19 @@ initVitals();
|
|||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
function renderApp() {
|
root.render(
|
||||||
root.render(
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<ModalsProvider>
|
||||||
<ModalsProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<HelmetProvider>
|
||||||
<HelmetProvider>
|
<PostHogProvider client={posthog}>
|
||||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
<App />
|
||||||
404 after a deploy is caught and recovered here instead of
|
</PostHogProvider>
|
||||||
blanking the whole app. */}
|
</HelmetProvider>
|
||||||
<ChunkLoadErrorBoundary>
|
</QueryClientProvider>
|
||||||
<App />
|
</ModalsProvider>
|
||||||
</ChunkLoadErrorBoundary>
|
</MantineProvider>
|
||||||
</HelmetProvider>
|
</BrowserRouter>,
|
||||||
</QueryClientProvider>
|
);
|
||||||
</ModalsProvider>
|
|
||||||
</MantineProvider>
|
|
||||||
</BrowserRouter>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initAnalytics() {
|
|
||||||
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
|
||||||
// self-hosted builds never download it. The gate is kept identical to the
|
|
||||||
// previous eager code so cloud analytics behavior is unchanged; the import is
|
|
||||||
// simply deferred behind it.
|
|
||||||
//
|
|
||||||
// Crucially this runs AFTER the immediate first render below, so first paint is
|
|
||||||
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
|
||||||
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
|
||||||
// working app without analytics instead of a permanently blank page.
|
|
||||||
//
|
|
||||||
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
|
||||||
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
|
||||||
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
|
||||||
// an already-initialized `client` is a no-op — all capture goes through the
|
|
||||||
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
|
||||||
// App (running every mount effect twice and dropping local state / focus /
|
|
||||||
// in-progress input on cloud cold-load) for no functional gain.
|
|
||||||
if (!(isCloud() && isPostHogEnabled)) return;
|
|
||||||
try {
|
|
||||||
const { default: posthog } = await import("posthog-js");
|
|
||||||
posthog.init(getPostHogKey(), {
|
|
||||||
api_host: getPostHogHost(),
|
|
||||||
defaults: "2025-05-24",
|
|
||||||
disable_session_recording: true,
|
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Analytics failed to load — degrade gracefully; the app already rendered.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
|
||||||
// cloud no longer blocks on the analytics import). The posthog singleton is
|
|
||||||
// initialized after, without re-rendering the tree.
|
|
||||||
renderApp();
|
|
||||||
void initAnalytics();
|
|
||||||
|
|||||||
@@ -63,20 +63,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
name: "vendor-mantine",
|
name: "vendor-mantine",
|
||||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||||
},
|
},
|
||||||
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
|
||||||
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
|
||||||
// a small module shared with the (eager) react-i18next runtime into
|
|
||||||
// that group chunk, which then drags the whole ~590KB editor engine
|
|
||||||
// into the eager modulepreload graph. Left to the default splitting,
|
|
||||||
// the editor engine stays in lazily-loaded chunks pulled only by the
|
|
||||||
// route-split editor/share pages. KaTeX is safe to group (nothing
|
|
||||||
// eager references it).
|
|
||||||
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
|
||||||
// node views (never in the startup path).
|
|
||||||
{
|
|
||||||
name: "vendor-katex",
|
|
||||||
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// #348 — debounce window for the per-page RAG re-embed job. Repeated saves
|
||||||
|
// within this window collapse to a single delayed job (coalesced by a stable
|
||||||
|
// jobId), so active editing does not pile up expensive re-embeds (external API
|
||||||
|
// + page_embeddings rewrite, concurrency 1). The worker reads the CURRENT page
|
||||||
|
// state at run time, so the last content within the window wins.
|
||||||
|
export const EMBED_DEBOUNCE_MS = 30 * 1000;
|
||||||
|
|||||||
@@ -431,7 +431,17 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
it('uses the canonical page.id (not the slugId doc name) for post-store side effects (#260)', async () => {
|
it('uses the canonical page.id (not the slugId doc name) for post-store side effects (#260)', async () => {
|
||||||
const SLUG = 'slug-1'; // persistedHumanPage.slugId; findById resolves it
|
const SLUG = 'slug-1'; // persistedHumanPage.slugId; findById resolves it
|
||||||
const document = ydocFor(doc('NEW AGENT CONTENT'));
|
const document = ydocFor(doc('NEW AGENT CONTENT'));
|
||||||
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW AGENT CONTENT'));
|
// #348 — the transclusion sync now runs only when the new OR the previously
|
||||||
|
// persisted content carries a transclusion-family node. Give the persisted
|
||||||
|
// (old) content a pageEmbed so the sync path is exercised and the #260
|
||||||
|
// UUID-vs-slugId contract asserted below is still verified.
|
||||||
|
pageRepo.findById.mockResolvedValue({
|
||||||
|
...persistedHumanPage('NEW AGENT CONTENT'),
|
||||||
|
content: {
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'pageEmbed', attrs: { sourcePageId: 'src-1' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
||||||
|
|
||||||
// A `page.<slugId>` document name (the bug's smoking gun), agent store over
|
// A `page.<slugId>` document name (the bug's smoking gun), agent store over
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ import {
|
|||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import {
|
import {
|
||||||
|
EMBED_DEBOUNCE_MS,
|
||||||
HISTORY_FAST_INTERVAL,
|
HISTORY_FAST_INTERVAL,
|
||||||
HISTORY_FAST_THRESHOLD,
|
HISTORY_FAST_THRESHOLD,
|
||||||
HISTORY_INTERVAL,
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
|
import { hasTransclusionFamilyNodes } from '../../core/page/transclusion/utils/transclusion-prosemirror.util';
|
||||||
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -415,7 +417,18 @@ export class PersistenceExtension implements Extension {
|
|||||||
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
// Use the canonical page UUID (page.id), not the doc-name id, which may be
|
||||||
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
// a slugId for a `page.<slugId>` doc (#260). The transclusion/reference
|
||||||
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
// syncs write uuid-typed columns, so a slugId here threw Postgres 22P02.
|
||||||
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
//
|
||||||
|
// #348 — skip the three sync SELECTs when neither the new content nor the
|
||||||
|
// previously-persisted content has any transclusion/reference/pageEmbed
|
||||||
|
// node: nothing to insert, and (the DB mirrors the old content) nothing to
|
||||||
|
// delete. Whenever either side has one, run the idempotent sync exactly as
|
||||||
|
// before so removals are still reconciled.
|
||||||
|
if (
|
||||||
|
hasTransclusionFamilyNodes(tiptapJson) ||
|
||||||
|
hasTransclusionFamilyNodes(page.content)
|
||||||
|
) {
|
||||||
|
await this.syncTransclusion(page.id, page.workspaceId, tiptapJson);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page) {
|
if (page) {
|
||||||
@@ -431,7 +444,17 @@ export class PersistenceExtension implements Extension {
|
|||||||
(m) => m.entityId,
|
(m) => m.entityId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userMentions.length > 0) {
|
// #348 — only enqueue when the mentioned-user set actually GAINED a member.
|
||||||
|
// The processor (processPageMention) already no-ops when every current
|
||||||
|
// mention was present before (newMentions.length === 0), so skipping the
|
||||||
|
// enqueue in that case is behavior-identical and avoids piling up no-op jobs
|
||||||
|
// on every save of a page that merely CONTAINS (unchanged) mentions.
|
||||||
|
const oldMentionedUserIdSet = new Set(oldMentionedUserIds);
|
||||||
|
const hasNewMentionedUser = userMentions.some(
|
||||||
|
(m) => !oldMentionedUserIdSet.has(m.entityId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasNewMentionedUser) {
|
||||||
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
|
||||||
userMentions: userMentions.map((m) => ({
|
userMentions: userMentions.map((m) => ({
|
||||||
userId: m.entityId,
|
userId: m.entityId,
|
||||||
@@ -446,12 +469,23 @@ export class PersistenceExtension implements Extension {
|
|||||||
} as IPageMentionNotificationJob);
|
} as IPageMentionNotificationJob);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
|
await this.aiQueue.add(
|
||||||
// Canonical UUID: the embedding reindex resolves pages by uuid, so a
|
QueueJob.PAGE_CONTENT_UPDATED,
|
||||||
// slugId here threw Postgres 22P02 invalid-uuid (#260).
|
{
|
||||||
pageIds: [page.id],
|
// Canonical UUID: the embedding reindex resolves pages by uuid, so a
|
||||||
workspaceId: page.workspaceId,
|
// slugId here threw Postgres 22P02 invalid-uuid (#260).
|
||||||
});
|
pageIds: [page.id],
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
},
|
||||||
|
// #348 — coalesce re-embeds during active editing. A stable per-page
|
||||||
|
// jobId + delay means repeated saves within EMBED_DEBOUNCE_MS collapse
|
||||||
|
// to one delayed job instead of one expensive re-embed per save. The
|
||||||
|
// worker reads the current page state at run time, so last content wins.
|
||||||
|
// BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is
|
||||||
|
// used; page.id is a UUID, so the id is unique per page. removeOnComplete
|
||||||
|
// (queue.module) frees the id after each run so the next window re-arms.
|
||||||
|
{ jobId: `embed-${page.id}`, delay: EMBED_DEBOUNCE_MS },
|
||||||
|
);
|
||||||
|
|
||||||
await this.enqueuePageHistory(page, lastUpdatedSource);
|
await this.enqueuePageHistory(page, lastUpdatedSource);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,13 @@ export class RedisSyncExtension<TCE extends CustomEvents> implements Extension {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async maintainLock(documentName: string) {
|
async maintainLock(documentName: string) {
|
||||||
|
// #348 — clear any existing timer for this document before installing a new
|
||||||
|
// one. Without this, a second maintainLock for the same document (a
|
||||||
|
// reload-without-unload) overwrites this.locks[documentName] and leaks the
|
||||||
|
// previous interval, which keeps firing SET forever with no way to clear it.
|
||||||
|
if (this.locks[documentName]) {
|
||||||
|
clearInterval(this.locks[documentName]);
|
||||||
|
}
|
||||||
this.locks[documentName] = setInterval(() => {
|
this.locks[documentName] = setInterval(() => {
|
||||||
this.pub.set(
|
this.pub.set(
|
||||||
this.getKey(documentName),
|
this.getKey(documentName),
|
||||||
|
|||||||
@@ -4,8 +4,21 @@ export const CacheKey = {
|
|||||||
`perm:space-roles:${userId}:${spaceId}`,
|
`perm:space-roles:${userId}:${spaceId}`,
|
||||||
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
|
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
|
||||||
`perm:can-edit:${userId}:${pageId}`,
|
`perm:can-edit:${userId}:${pageId}`,
|
||||||
|
// #348 — DomainMiddleware workspace resolution. Self-hosted resolves the single
|
||||||
|
// workspace (constant key); cloud resolves by the request subdomain (lowercased
|
||||||
|
// to match the case-insensitive `LOWER(hostname)` lookup). Every WorkspaceRepo
|
||||||
|
// mutator busts these, so staleness is bounded by both explicit invalidation and
|
||||||
|
// the short TTL below.
|
||||||
|
WORKSPACE_SELF_HOSTED: 'workspace:self-hosted',
|
||||||
|
WORKSPACE_BY_HOST: (subdomain: string) =>
|
||||||
|
`workspace:byhost:${subdomain.toLowerCase()}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Permission caches dedupe repeated checks within and across short request bursts.
|
// Permission caches dedupe repeated checks within and across short request bursts.
|
||||||
// 5s keeps staleness on revocations bounded.
|
// 5s keeps staleness on revocations bounded.
|
||||||
export const PERMISSION_CACHE_TTL_MS = 5_000;
|
export const PERMISSION_CACHE_TTL_MS = 5_000;
|
||||||
|
|
||||||
|
// #348 — workspace row changes rarely; a short TTL bounds staleness of
|
||||||
|
// security-relevant fields (enforceSso/enforceMfa/status) even if an explicit
|
||||||
|
// bust is ever missed, while still removing the per-request workspace query.
|
||||||
|
export const WORKSPACE_CACHE_TTL_MS = 15_000;
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
import { Injectable, NestMiddleware, NotFoundException } from '@nestjs/common';
|
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { withCache } from '../helpers/with-cache';
|
||||||
|
import { CacheKey, WORKSPACE_CACHE_TTL_MS } from '../helpers/cache-keys';
|
||||||
|
|
||||||
|
// #348 — timestamptz columns on the workspace row. The cache store (Keyv/Redis)
|
||||||
|
// JSON-serializes values, so a cached workspace comes back with these fields as
|
||||||
|
// ISO strings. Reviving them to Date keeps the cached path byte-identical to the
|
||||||
|
// direct DB path (postgres.js returns Date), so nothing downstream can observe a
|
||||||
|
// cache hit vs miss. Idempotent: `new Date(date)` on an already-Date value is a
|
||||||
|
// no-op-equivalent. Keep in sync with the workspace timestamptz columns.
|
||||||
|
const WORKSPACE_DATE_FIELDS: Array<keyof Workspace> = [
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'deletedAt',
|
||||||
|
'trialEndAt',
|
||||||
|
];
|
||||||
|
|
||||||
|
function reviveWorkspaceDates(workspace: Workspace): Workspace {
|
||||||
|
for (const field of WORKSPACE_DATE_FIELDS) {
|
||||||
|
const value = workspace[field];
|
||||||
|
if (value != null) {
|
||||||
|
(workspace as any)[field] = new Date(value as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DomainMiddleware implements NestMiddleware {
|
export class DomainMiddleware implements NestMiddleware {
|
||||||
constructor(
|
constructor(
|
||||||
private workspaceRepo: WorkspaceRepo,
|
private workspaceRepo: WorkspaceRepo,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
) {}
|
) {}
|
||||||
async use(
|
async use(
|
||||||
req: FastifyRequest['raw'],
|
req: FastifyRequest['raw'],
|
||||||
@@ -15,13 +44,21 @@ export class DomainMiddleware implements NestMiddleware {
|
|||||||
next: () => void,
|
next: () => void,
|
||||||
) {
|
) {
|
||||||
if (this.environmentService.isSelfHosted()) {
|
if (this.environmentService.isSelfHosted()) {
|
||||||
const workspace = await this.workspaceRepo.findFirst();
|
// #348 — cache the single-workspace lookup that runs on every request.
|
||||||
|
// Invalidated by every WorkspaceRepo mutator (see bustWorkspaceCache).
|
||||||
|
const workspace = await withCache(
|
||||||
|
this.cacheManager,
|
||||||
|
CacheKey.WORKSPACE_SELF_HOSTED,
|
||||||
|
WORKSPACE_CACHE_TTL_MS,
|
||||||
|
() => this.workspaceRepo.findFirst(),
|
||||||
|
);
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
//throw new NotFoundException('Workspace not found');
|
//throw new NotFoundException('Workspace not found');
|
||||||
(req as any).workspaceId = null;
|
(req as any).workspaceId = null;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reviveWorkspaceDates(workspace);
|
||||||
// TODO: unify
|
// TODO: unify
|
||||||
(req as any).workspaceId = workspace.id;
|
(req as any).workspaceId = workspace.id;
|
||||||
(req as any).workspace = workspace;
|
(req as any).workspace = workspace;
|
||||||
@@ -29,13 +66,21 @@ export class DomainMiddleware implements NestMiddleware {
|
|||||||
const header = req.headers.host;
|
const header = req.headers.host;
|
||||||
const subdomain = header.split('.')[0];
|
const subdomain = header.split('.')[0];
|
||||||
|
|
||||||
const workspace = await this.workspaceRepo.findByHostname(subdomain);
|
// #348 — cache per-subdomain workspace resolution. Keyed by subdomain (the
|
||||||
|
// hostname column); busted per hostname by every WorkspaceRepo mutator.
|
||||||
|
const workspace = await withCache(
|
||||||
|
this.cacheManager,
|
||||||
|
CacheKey.WORKSPACE_BY_HOST(subdomain),
|
||||||
|
WORKSPACE_CACHE_TTL_MS,
|
||||||
|
() => this.workspaceRepo.findByHostname(subdomain),
|
||||||
|
);
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
(req as any).workspaceId = null;
|
(req as any).workspaceId = null;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reviveWorkspaceDates(workspace);
|
||||||
(req as any).workspaceId = workspace.id;
|
(req as any).workspaceId = workspace.id;
|
||||||
(req as any).workspace = workspace;
|
(req as any).workspace = workspace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,21 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
// #348 — reuse the workspace DomainMiddleware already loaded for this request
|
||||||
|
// instead of re-querying it. `validate()` above has confirmed
|
||||||
|
// `req.raw.workspaceId === payload.workspaceId` (or that it is unset), and the
|
||||||
|
// middleware sets `req.raw.workspace` alongside `req.raw.workspaceId` from the
|
||||||
|
// SAME workspace row, so when the ids match this is that row. NOTE it is the
|
||||||
|
// middleware's `selectAll` object (a superset of the fallback `findById` base
|
||||||
|
// fields — it also carries licenseKey/auditRetentionDays); that is harmless
|
||||||
|
// here because every consumer reads this workspace via the AuthWorkspace
|
||||||
|
// decorator, which already preferred `req.raw.workspace` (the selectAll object)
|
||||||
|
// over `req.user.workspace` before this change. Fall back to the query if the
|
||||||
|
// middleware did not populate it (a path that bypasses DomainMiddleware).
|
||||||
|
const workspace =
|
||||||
|
req.raw.workspace && req.raw.workspaceId === payload.workspaceId
|
||||||
|
? req.raw.workspace
|
||||||
|
: await this.workspaceRepo.findById(payload.workspaceId);
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export class FavoriteService {
|
|||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
pageIds: result.items,
|
pageIds: result.items,
|
||||||
userId,
|
userId,
|
||||||
|
// #348 — favorites load at app-start; enable the workspace short-circuit.
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
const accessibleSet = new Set(accessibleIds);
|
const accessibleSet = new Set(accessibleIds);
|
||||||
result.items = result.items.filter((id) => accessibleSet.has(id));
|
result.items = result.items.filter((id) => accessibleSet.has(id));
|
||||||
@@ -125,6 +127,8 @@ export class FavoriteService {
|
|||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
pageIds,
|
pageIds,
|
||||||
userId,
|
userId,
|
||||||
|
// #348 — workspace-level short-circuit for the favorites list.
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
accessiblePageSet = new Set(accessibleIds);
|
accessiblePageSet = new Set(accessibleIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export class NotificationController {
|
|||||||
@Body() dto: ListNotificationsDto,
|
@Body() dto: ListNotificationsDto,
|
||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
) {
|
) {
|
||||||
return this.notificationService.findByUserId(user.id, dto, dto.type);
|
return this.notificationService.findByUserId(
|
||||||
|
user.id,
|
||||||
|
dto,
|
||||||
|
dto.type,
|
||||||
|
user.workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export class NotificationService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
type: NotificationTab = 'all',
|
type: NotificationTab = 'all',
|
||||||
|
workspaceId?: string | null,
|
||||||
) {
|
) {
|
||||||
const result = await this.notificationRepo.findByUserId(
|
const result = await this.notificationRepo.findByUserId(
|
||||||
userId,
|
userId,
|
||||||
@@ -61,6 +62,8 @@ export class NotificationService {
|
|||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
pageIds,
|
pageIds,
|
||||||
userId,
|
userId,
|
||||||
|
// #348 — notifications list; enable the workspace short-circuit.
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
const accessibleSet = new Set(accessiblePageIds);
|
const accessibleSet = new Set(accessiblePageIds);
|
||||||
|
|
||||||
|
|||||||
@@ -446,7 +446,11 @@ export class PageController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.pageService.getRecentPages(user.id, pagination);
|
return this.pageService.getRecentPages(
|
||||||
|
user.id,
|
||||||
|
pagination,
|
||||||
|
user.workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@@ -469,7 +473,13 @@ export class PageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.pageService.getCreatedByPages(targetUserId, user.id, pagination, dto.spaceId);
|
return this.pageService.getCreatedByPages(
|
||||||
|
targetUserId,
|
||||||
|
user.id,
|
||||||
|
pagination,
|
||||||
|
dto.spaceId,
|
||||||
|
user.workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -1165,6 +1165,7 @@ export class PageService {
|
|||||||
async getRecentPages(
|
async getRecentPages(
|
||||||
userId: string,
|
userId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
|
workspaceId?: string | null,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
const result = await this.pageRepo.getRecentPages(userId, pagination);
|
const result = await this.pageRepo.getRecentPages(userId, pagination);
|
||||||
|
|
||||||
@@ -1174,6 +1175,8 @@ export class PageService {
|
|||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
pageIds,
|
pageIds,
|
||||||
userId,
|
userId,
|
||||||
|
// #348 — cross-space "recent"; enable the workspace short-circuit.
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
const accessibleSet = new Set(accessibleIds);
|
const accessibleSet = new Set(accessibleIds);
|
||||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||||
@@ -1187,6 +1190,7 @@ export class PageService {
|
|||||||
requestingUserId: string,
|
requestingUserId: string,
|
||||||
pagination: PaginationOptions,
|
pagination: PaginationOptions,
|
||||||
spaceId?: string,
|
spaceId?: string,
|
||||||
|
workspaceId?: string | null,
|
||||||
): Promise<CursorPaginationResult<Page>> {
|
): Promise<CursorPaginationResult<Page>> {
|
||||||
const result = await this.pageRepo.getCreatedByPages(
|
const result = await this.pageRepo.getCreatedByPages(
|
||||||
creatorId,
|
creatorId,
|
||||||
@@ -1201,6 +1205,9 @@ export class PageService {
|
|||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
pageIds,
|
pageIds,
|
||||||
userId: requestingUserId,
|
userId: requestingUserId,
|
||||||
|
spaceId,
|
||||||
|
// #348 — enable the workspace short-circuit when not space-scoped.
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
const accessibleSet = new Set(accessibleIds);
|
const accessibleSet = new Set(accessibleIds);
|
||||||
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
result.items = result.items.filter((p) => accessibleSet.has(p.id));
|
||||||
|
|||||||
@@ -93,6 +93,41 @@ function collectNodes<T>(
|
|||||||
return Array.from(byKey.values());
|
return Array.from(byKey.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 — cheap early-exit probe: does this doc contain ANY node the transclusion
|
||||||
|
* syncs care about (`transclusionSource` / `transclusionReference` / `pageEmbed`)?
|
||||||
|
* Lets the collab store skip the three sync SELECTs when neither the previous nor
|
||||||
|
* the new content has any such node — there is nothing to insert, and (since the
|
||||||
|
* DB mirrors the previously-persisted content) nothing to delete. Walks once and
|
||||||
|
* short-circuits on the first match; uses the same depth ceiling as the
|
||||||
|
* collectors. Deliberately does NOT skip `transclusionSource` subtrees: it only
|
||||||
|
* answers "any node present?", so descending everywhere is strictly conservative
|
||||||
|
* (it can never wrongly report "none").
|
||||||
|
*/
|
||||||
|
export function hasTransclusionFamilyNodes(doc: unknown): boolean {
|
||||||
|
const visit = (node: any, depth: number): boolean => {
|
||||||
|
if (!node || typeof node !== 'object') return false;
|
||||||
|
if (depth > MAX_PM_WALK_DEPTH) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.type === TRANSCLUSION_TYPE ||
|
||||||
|
node.type === REFERENCE_TYPE ||
|
||||||
|
node.type === PAGE_EMBED_TYPE
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
if (visit(child, depth + 1)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return visit(doc, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks a ProseMirror JSON document and returns one snapshot per top-level
|
* Walks a ProseMirror JSON document and returns one snapshot per top-level
|
||||||
* `transclusion` node. Does not recurse into transclusions (schema disallows
|
* `transclusion` node. Does not recurse into transclusions (schema disallows
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export class SearchService {
|
|||||||
pageIds,
|
pageIds,
|
||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
spaceId: searchParams.spaceId,
|
spaceId: searchParams.spaceId,
|
||||||
|
// #348 — enables the workspace-level short-circuit when not space-scoped.
|
||||||
|
workspaceId: opts.workspaceId,
|
||||||
});
|
});
|
||||||
const accessibleSet = new Set(accessibleIds);
|
const accessibleSet = new Set(accessibleIds);
|
||||||
results = results.filter((r: any) => accessibleSet.has(r.id));
|
results = results.filter((r: any) => accessibleSet.has(r.id));
|
||||||
@@ -266,6 +268,8 @@ export class SearchService {
|
|||||||
await this.pagePermissionRepo.filterAccessiblePageIds({
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
pageIds,
|
pageIds,
|
||||||
userId,
|
userId,
|
||||||
|
// #348 — workspace-level short-circuit for the suggest path.
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
const accessibleSet = new Set(accessibleIds);
|
const accessibleSet = new Set(accessibleIds);
|
||||||
pages = pages.filter((p) => accessibleSet.has(p.id));
|
pages = pages.filter((p) => accessibleSet.has(p.id));
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 — targeted hot-path indexes.
|
||||||
|
*
|
||||||
|
* 1. GIN trigram indexes for `/search/suggest`. That endpoint runs a
|
||||||
|
* leading-wildcard `LOWER(f_unaccent(col)) LIKE '%q%'` per keystroke, which
|
||||||
|
* is a sequential scan without a trigram index. The index EXPRESSIONS below
|
||||||
|
* are `LOWER(f_unaccent(title|name))`, matching the predicates in
|
||||||
|
* search.service.ts exactly so the planner uses them (verified with EXPLAIN:
|
||||||
|
* the suggest predicate resolves to a Bitmap Index Scan on these indexes).
|
||||||
|
*
|
||||||
|
* IMMUTABLE-wrapper fix (required for the index to build): `f_unaccent` was
|
||||||
|
* defined as `SELECT unaccent('unaccent', $1)` (the two-arg, dictionary-named
|
||||||
|
* unaccent). That body CANNOT be used in an index expression: when Postgres
|
||||||
|
* inlines the IMMUTABLE SQL wrapper while building the index it fails to
|
||||||
|
* resolve the two-arg call (`function unaccent(unknown, text) does not exist`,
|
||||||
|
* the `'unaccent'` literal loses its regdictionary coercion). The single-arg
|
||||||
|
* `unaccent($1)` is the same operation (the default text-search dictionary IS
|
||||||
|
* `unaccent`; verified byte-equal on accented samples), and — crucially —
|
||||||
|
* SCHEMA-QUALIFIED as `public.unaccent($1)` it inlines cleanly, so the index
|
||||||
|
* builds. We therefore `CREATE OR REPLACE` `f_unaccent` to the qualified
|
||||||
|
* single-arg body. This is output-identical for every existing caller (the
|
||||||
|
* tsvector trigger, the main `tsv @@` search, and the suggest LIKE), so no
|
||||||
|
* reindex/backfill is needed; `down()` restores the original two-arg body.
|
||||||
|
* (The `unaccent` extension is installed in `public` in this codebase, which
|
||||||
|
* is why `public.unaccent` is the correct qualification.)
|
||||||
|
*
|
||||||
|
* 2. Composite indexes for two ORDER-BY-only-on-id queries that currently sort
|
||||||
|
* on top of a created_at index:
|
||||||
|
* - page_history: `findPageHistoryByPageId` does WHERE page_id ORDER BY id
|
||||||
|
* DESC, but only `(page_id, created_at DESC)` exists → extra sort.
|
||||||
|
* - comments: `findPageComments` does WHERE page_id ORDER BY id ASC, but only
|
||||||
|
* `(page_id)` exists → extra sort.
|
||||||
|
*
|
||||||
|
* DEPLOY-TIME LOCK WARNING: these are plain (non-CONCURRENT) CREATE INDEX
|
||||||
|
* statements — CONCURRENTLY is impossible because Kysely runs each migration in a
|
||||||
|
* transaction. They take a SHARE lock that BLOCKS writes (INSERT/UPDATE/DELETE) on
|
||||||
|
* pages/users/groups/comments/page_history for the duration of the build. The two
|
||||||
|
* GIN trigram builds on pages.title / users.name are the slow ones and can take
|
||||||
|
* minutes on a large tenant → a write-outage window during the deploy migration.
|
||||||
|
* For large installations, run this migration in a maintenance window, or build
|
||||||
|
* the trigram indexes out-of-band with CREATE INDEX CONCURRENTLY before deploying
|
||||||
|
* (then this migration's `IF NOT EXISTS` is a no-op). Small/typical tenants are
|
||||||
|
* unaffected.
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// Index-compatible, output-identical redefinition of f_unaccent (see header).
|
||||||
|
await sql`
|
||||||
|
CREATE OR REPLACE FUNCTION f_unaccent(text)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE sql
|
||||||
|
IMMUTABLE PARALLEL SAFE STRICT
|
||||||
|
AS $func$
|
||||||
|
SELECT public.unaccent($1);
|
||||||
|
$func$
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// Search-suggest trigram indexes. Expressions match search.service.ts.
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pages_title_trgm
|
||||||
|
ON pages USING gin ((LOWER(f_unaccent(title))) gin_trgm_ops)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_name_trgm
|
||||||
|
ON users USING gin ((LOWER(f_unaccent(name))) gin_trgm_ops)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_groups_name_trgm
|
||||||
|
ON groups USING gin ((LOWER(f_unaccent(name))) gin_trgm_ops)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// page_history: WHERE page_id ORDER BY id DESC (findPageHistoryByPageId).
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_history_page_id
|
||||||
|
ON page_history (page_id, id DESC)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// comments: WHERE page_id ORDER BY id ASC (findPageComments).
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_page_id_id
|
||||||
|
ON comments (page_id, id)
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
// page_access(workspace_id): #348 made hasRestrictedPagesInWorkspace uncached
|
||||||
|
// (F1 fix), so `EXISTS(SELECT 1 FROM page_access WHERE workspace_id=?)` now runs
|
||||||
|
// per-request on every whole-workspace list endpoint (global search + suggest,
|
||||||
|
// favorites, notifications, recent, created-by). page_access only had a
|
||||||
|
// space_id index → that EXISTS was a seq scan in the common zero-restriction
|
||||||
|
// case. This index makes it an index-only existence probe.
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_page_access_workspace_id
|
||||||
|
ON page_access (workspace_id)
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
// Drop the expression indexes before restoring the function body.
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_pages_title_trgm`.execute(db);
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_users_name_trgm`.execute(db);
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_groups_name_trgm`.execute(db);
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_page_history_page_id`.execute(db);
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_comments_page_id_id`.execute(db);
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_page_access_workspace_id`.execute(db);
|
||||||
|
|
||||||
|
// Restore the original two-arg (dictionary-named) f_unaccent body.
|
||||||
|
await sql`
|
||||||
|
CREATE OR REPLACE FUNCTION f_unaccent(text)
|
||||||
|
RETURNS text
|
||||||
|
LANGUAGE sql
|
||||||
|
IMMUTABLE PARALLEL SAFE STRICT
|
||||||
|
AS $func$
|
||||||
|
SELECT unaccent('unaccent', $1);
|
||||||
|
$func$
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
@@ -657,8 +657,9 @@ export class PagePermissionRepo {
|
|||||||
pageIds: string[];
|
pageIds: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const { pageIds, userId, spaceId } = opts;
|
const { pageIds, userId, spaceId, workspaceId } = opts;
|
||||||
if (pageIds.length === 0) return [];
|
if (pageIds.length === 0) return [];
|
||||||
|
|
||||||
if (spaceId) {
|
if (spaceId) {
|
||||||
@@ -666,6 +667,17 @@ export class PagePermissionRepo {
|
|||||||
if (!hasRestrictions) {
|
if (!hasRestrictions) {
|
||||||
return pageIds;
|
return pageIds;
|
||||||
}
|
}
|
||||||
|
} else if (workspaceId) {
|
||||||
|
// #348 — whole-workspace callers (no spaceId: favorites, notifications,
|
||||||
|
// recent, created-by, global search) skip the recursive-ancestor CTE + anti
|
||||||
|
// -join entirely when the workspace has ZERO restricted pages. When any
|
||||||
|
// restriction DOES exist, fall through to the identical CTE below, so
|
||||||
|
// behavior is unchanged whenever restrictions are present.
|
||||||
|
const hasRestrictions =
|
||||||
|
await this.hasRestrictedPagesInWorkspace(workspaceId);
|
||||||
|
if (!hasRestrictions) {
|
||||||
|
return pageIds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await this.db
|
const results = await this.db
|
||||||
@@ -903,6 +915,39 @@ export class PagePermissionRepo {
|
|||||||
return Boolean(result?.exists);
|
return Boolean(result?.exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace-level analogue of hasRestrictedPagesInSpace: does ANY page in the
|
||||||
|
* whole workspace carry a restriction? Lets whole-workspace access filters
|
||||||
|
* short-circuit the recursive-ancestor CTE when nothing is restricted at all.
|
||||||
|
*
|
||||||
|
* UNCACHED (like the sibling hasRestrictedPagesInSpace) — a single cheap
|
||||||
|
* `EXISTS(pageAccess WHERE workspaceId=?)` per call. This is an ACCESS-CONTROL
|
||||||
|
* gate on whole-workspace list endpoints, so it must never go stale: caching it
|
||||||
|
* (even 5s) reintroduced a leak the space-path never had — a concurrent
|
||||||
|
* whole-workspace read in the insert->commit window of the FIRST restricted page
|
||||||
|
* could re-populate `false` under withCache (read-then-set, no del-during-read
|
||||||
|
* guard) and override the insert bust, leaking that page to unauthorized users
|
||||||
|
* for up to the TTL (#348 review F1). An uncached EXISTS removes both the
|
||||||
|
* cache/DB asymmetry with hasRestrictedPagesInSpace and that race; the space
|
||||||
|
* path already accepts this exact per-call cost.
|
||||||
|
*/
|
||||||
|
async hasRestrictedPagesInWorkspace(workspaceId: string): Promise<boolean> {
|
||||||
|
const result = await this.db
|
||||||
|
.selectNoFrom((eb) =>
|
||||||
|
eb
|
||||||
|
.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('pageAccess')
|
||||||
|
.select(sql`1`.as('one'))
|
||||||
|
.where('pageAccess.workspaceId', '=', workspaceId),
|
||||||
|
)
|
||||||
|
.as('exists'),
|
||||||
|
)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
return Boolean(result?.exists);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a list of parent page IDs, return which ones have at least one accessible child.
|
* Given a list of parent page IDs, return which ones have at least one accessible child.
|
||||||
* Efficient batch query for sidebar hasChildren calculation.
|
* Efficient batch query for sidebar hasChildren calculation.
|
||||||
|
|||||||
@@ -581,6 +581,9 @@ export class PageRepo {
|
|||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
|
// NOTE: `content` IS needed here — the trash UI reads page.content to render
|
||||||
|
// the deleted-page preview modal (trash.tsx handlePageClick ->
|
||||||
|
// TrashPageContentModal pageContent). Do NOT drop it (see #348 review F3).
|
||||||
.select('content')
|
.select('content')
|
||||||
.select((eb) => this.withSpace(eb))
|
.select((eb) => this.withSpace(eb))
|
||||||
.select((eb) => this.withDeletedBy(eb))
|
.select((eb) => this.withDeletedBy(eb))
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||||
import { dbOrTx } from '../../utils';
|
import { dbOrTx } from '../../utils';
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { DB, Workspaces } from '@docmost/db/types/db';
|
import { DB, Workspaces } from '@docmost/db/types/db';
|
||||||
|
import { CacheKey } from '../../../common/helpers/cache-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable `settings.ai.provider` keys, enforced at this generic SQL layer. This
|
* Writable `settings.ai.provider` keys, enforced at this generic SQL layer. This
|
||||||
@@ -61,7 +64,34 @@ export class WorkspaceRepo {
|
|||||||
'temporaryNoteHours',
|
'temporaryNoteHours',
|
||||||
'isScimEnabled',
|
'isScimEnabled',
|
||||||
];
|
];
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 — bust the DomainMiddleware workspace caches after any workspace write.
|
||||||
|
* Deletes BOTH the self-hosted (constant) key and the cloud per-hostname key so
|
||||||
|
* a single implementation covers either deployment mode (the irrelevant key is a
|
||||||
|
* harmless no-op). Best-effort: a cache error must never fail the write, and a
|
||||||
|
* missed bust is bounded by WORKSPACE_CACHE_TTL_MS. Note: a hostname RENAME only
|
||||||
|
* busts the NEW hostname's key (the row returned here carries the new hostname);
|
||||||
|
* the old key expires via TTL.
|
||||||
|
*/
|
||||||
|
private async bustWorkspaceCache(
|
||||||
|
workspace?: Pick<Workspace, 'hostname'> | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.cacheManager.del(CacheKey.WORKSPACE_SELF_HOSTED);
|
||||||
|
if (workspace?.hostname) {
|
||||||
|
await this.cacheManager.del(
|
||||||
|
CacheKey.WORKSPACE_BY_HOST(workspace.hostname),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cache is best-effort; TTL is the backstop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -144,12 +174,14 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({ ...updatableWorkspace, updatedAt: new Date() })
|
.set({ ...updatableWorkspace, updatedAt: new Date() })
|
||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertWorkspace(
|
async insertWorkspace(
|
||||||
@@ -157,11 +189,14 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.insertInto('workspaces')
|
.insertInto('workspaces')
|
||||||
.values(insertableWorkspace)
|
.values(insertableWorkspace)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
// Bust the cached "not found" so a fresh install / new tenant is seen at once.
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async count(): Promise<number> {
|
async count(): Promise<number> {
|
||||||
@@ -203,7 +238,7 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
) {
|
) {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
@@ -214,6 +249,8 @@ export class WorkspaceRepo {
|
|||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAiSettings(
|
async updateAiSettings(
|
||||||
@@ -223,7 +260,7 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
) {
|
) {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
@@ -234,6 +271,8 @@ export class WorkspaceRepo {
|
|||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -272,7 +311,7 @@ export class WorkspaceRepo {
|
|||||||
entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
|
entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
|
||||||
)})`
|
)})`
|
||||||
: sql`'{}'::jsonb`;
|
: sql`'{}'::jsonb`;
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
|
settings: sql`COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
|
||||||
@@ -287,6 +326,8 @@ export class WorkspaceRepo {
|
|||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,7 +344,7 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
) {
|
) {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
@@ -313,6 +354,8 @@ export class WorkspaceRepo {
|
|||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSharingSettings(
|
async updateSharingSettings(
|
||||||
@@ -322,7 +365,7 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
) {
|
) {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
@@ -333,6 +376,8 @@ export class WorkspaceRepo {
|
|||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTemplateSettings(
|
async updateTemplateSettings(
|
||||||
@@ -342,7 +387,7 @@ export class WorkspaceRepo {
|
|||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
) {
|
) {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
return db
|
const workspace = await db
|
||||||
.updateTable('workspaces')
|
.updateTable('workspaces')
|
||||||
.set({
|
.set({
|
||||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
@@ -353,6 +398,8 @@ export class WorkspaceRepo {
|
|||||||
.where('id', '=', workspaceId)
|
.where('id', '=', workspaceId)
|
||||||
.returning(this.baseFields)
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
await this.bustWorkspaceCache(workspace);
|
||||||
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
createUser,
|
||||||
|
createPage,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 — the whole-workspace access-filter short-circuit is an ACCESS-CONTROL
|
||||||
|
* path, so it must produce the SAME result as the full recursive-ancestor CTE.
|
||||||
|
*
|
||||||
|
* filterAccessiblePageIds({ workspaceId }) (no spaceId — the favorites /
|
||||||
|
* notifications / recent / created-by / global-search callers) skips the CTE only
|
||||||
|
* when the workspace has ZERO restricted pages. A page is "restricted &
|
||||||
|
* inaccessible" when it (or an ancestor) has a `pageAccess` row and the user has
|
||||||
|
* no matching `pagePermissions`. Driven against real Postgres, asserts:
|
||||||
|
* 1. zero restrictions -> short-circuit returns the full input set;
|
||||||
|
* 2. a restriction present -> the CTE runs and drops the page the user can't
|
||||||
|
* reach while keeping the reachable ones (behavior unchanged);
|
||||||
|
* 3. inserting the FIRST pageAccess flips hasRestrictedPagesInWorkspace
|
||||||
|
* false -> true immediately (the 0->1 transition — now uncached, no stale
|
||||||
|
* window, review F1); it is scoped per workspace.
|
||||||
|
*/
|
||||||
|
describe('#348 filterAccessiblePageIds workspace short-circuit (real PG)', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let repo: PagePermissionRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let otherWorkspaceId: string;
|
||||||
|
let userId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
// hasRestrictedPagesInWorkspace is now uncached, and no other cached
|
||||||
|
// permission path is exercised here, so a no-op cache stub suffices.
|
||||||
|
const cacheStub = {
|
||||||
|
get: async () => undefined,
|
||||||
|
set: async () => undefined,
|
||||||
|
del: async () => undefined,
|
||||||
|
} as never;
|
||||||
|
repo = new PagePermissionRepo(db, new GroupRepo(db), cacheStub);
|
||||||
|
|
||||||
|
const ws = await createWorkspace(db);
|
||||||
|
workspaceId = ws.id;
|
||||||
|
const other = await createWorkspace(db);
|
||||||
|
otherWorkspaceId = other.id;
|
||||||
|
const user = await createUser(db, workspaceId);
|
||||||
|
userId = user.id;
|
||||||
|
const space = await createSpace(db, workspaceId);
|
||||||
|
spaceId = space.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero restrictions: short-circuit returns the full input set', async () => {
|
||||||
|
const p1 = await createPage(db, { workspaceId, spaceId });
|
||||||
|
const p2 = await createPage(db, { workspaceId, spaceId });
|
||||||
|
|
||||||
|
expect(await repo.hasRestrictedPagesInWorkspace(workspaceId)).toBe(false);
|
||||||
|
|
||||||
|
const ids = [p1.id, p2.id];
|
||||||
|
const filtered = await repo.filterAccessiblePageIds({
|
||||||
|
pageIds: ids,
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
expect(new Set(filtered)).toEqual(new Set(ids));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a restriction present: filters out the page the user cannot reach', async () => {
|
||||||
|
const openPage = await createPage(db, { workspaceId, spaceId });
|
||||||
|
const restrictedPage = await createPage(db, { workspaceId, spaceId });
|
||||||
|
|
||||||
|
// Add a pageAccess row on restrictedPage with NO matching pagePermissions for
|
||||||
|
// `userId` → the CTE anti-join marks it inaccessible for this user.
|
||||||
|
await db
|
||||||
|
.insertInto('pageAccess')
|
||||||
|
.values({
|
||||||
|
pageId: restrictedPage.id,
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
accessLevel: 'read',
|
||||||
|
creatorId: userId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// 0->1 transition is reflected immediately (uncached).
|
||||||
|
expect(await repo.hasRestrictedPagesInWorkspace(workspaceId)).toBe(true);
|
||||||
|
|
||||||
|
const filtered = await repo.filterAccessiblePageIds({
|
||||||
|
pageIds: [openPage.id, restrictedPage.id],
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
expect(filtered).toContain(openPage.id);
|
||||||
|
expect(filtered).not.toContain(restrictedPage.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasRestrictedPagesInWorkspace is scoped per workspace', async () => {
|
||||||
|
// The other workspace has no pageAccess rows → still false, unaffected by the
|
||||||
|
// restriction added above in `workspaceId`.
|
||||||
|
expect(await repo.hasRestrictedPagesInWorkspace(otherWorkspaceId)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,25 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
|
import { CacheKey } from 'src/common/helpers/cache-keys';
|
||||||
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
||||||
|
|
||||||
|
// A minimal Map-backed cache double with a working `del` (the previous `{}` stub
|
||||||
|
// made bustWorkspaceCache's `del` throw into its own try/catch, so the #348
|
||||||
|
// invalidation was never actually exercised — review F6).
|
||||||
|
function makeCacheDouble() {
|
||||||
|
const store = new Map<string, unknown>();
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
get: async (k: string) => store.get(k),
|
||||||
|
set: async (k: string, v: unknown) => {
|
||||||
|
store.set(k, v);
|
||||||
|
},
|
||||||
|
del: async (k: string) => {
|
||||||
|
store.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A — WorkspaceRepo.updateSetting jsonb-MERGE (the html-embed kill-switch
|
* A — WorkspaceRepo.updateSetting jsonb-MERGE (the html-embed kill-switch
|
||||||
* write-half). Setting a single top-level key must NOT clobber sibling
|
* write-half). Setting a single top-level key must NOT clobber sibling
|
||||||
@@ -15,7 +33,9 @@ describe('WorkspaceRepo.updateSetting (jsonb merge) [integration]', () => {
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
db = getTestDb();
|
db = getTestDb();
|
||||||
// Repos are plain classes taking @InjectKysely() db — instantiate directly.
|
// Repos are plain classes taking @InjectKysely() db — instantiate directly.
|
||||||
repo = new WorkspaceRepo(db as any);
|
// 2nd arg is CACHE_MANAGER (used only to bust the #348 workspace cache); a
|
||||||
|
// stub is fine here since bustWorkspaceCache is best-effort (try/catch).
|
||||||
|
repo = new WorkspaceRepo(db as any, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -58,3 +78,62 @@ describe('WorkspaceRepo.updateSetting (jsonb merge) [integration]', () => {
|
|||||||
expect(updated.settings).toEqual({ htmlEmbed: false });
|
expect(updated.settings).toEqual({ htmlEmbed: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #348 F6 — the DomainMiddleware workspace cache (WORKSPACE_SELF_HOSTED /
|
||||||
|
* WORKSPACE_BY_HOST, 15s TTL) caches security-relevant fields (enforceSso/
|
||||||
|
* enforceMfa/status). Its correctness rests entirely on bustWorkspaceCache being
|
||||||
|
* called from every mutator. This exercises the real invalidation with a working
|
||||||
|
* cache double (not the {} stub, whose del throws-and-swallows): warm the cache
|
||||||
|
* like DomainMiddleware, mutate, and assert the busted key is gone so a stale
|
||||||
|
* workspace row can't outlive the mutation.
|
||||||
|
*/
|
||||||
|
describe('WorkspaceRepo bustWorkspaceCache invalidation [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = getTestDb();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSetting busts the self-hosted workspace cache key', async () => {
|
||||||
|
const cache = makeCacheDouble();
|
||||||
|
const repo = new WorkspaceRepo(db as any, cache as any);
|
||||||
|
const ws = await createWorkspace(db, { settings: {} });
|
||||||
|
|
||||||
|
// Warm the cache as DomainMiddleware would (self-hosted key).
|
||||||
|
cache.store.set(CacheKey.WORKSPACE_SELF_HOSTED, ws);
|
||||||
|
expect(cache.store.has(CacheKey.WORKSPACE_SELF_HOSTED)).toBe(true);
|
||||||
|
|
||||||
|
await repo.updateSetting(ws.id, 'htmlEmbed', true);
|
||||||
|
|
||||||
|
// The mutation must have invalidated the cached row.
|
||||||
|
expect(cache.store.has(CacheKey.WORKSPACE_SELF_HOSTED)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSharingSettings busts the by-host workspace cache key too', async () => {
|
||||||
|
const cache = makeCacheDouble();
|
||||||
|
const repo = new WorkspaceRepo(db as any, cache as any);
|
||||||
|
const ws = await createWorkspace(db, { settings: {} });
|
||||||
|
// createWorkspace assigns a unique hostname; read it back for the by-host key.
|
||||||
|
const { hostname } = await db
|
||||||
|
.selectFrom('workspaces')
|
||||||
|
.select(['hostname'])
|
||||||
|
.where('id', '=', ws.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
// Warm BOTH keys (self-hosted + by-host); the by-host bust needs the row's
|
||||||
|
// hostname, which the mutator returns from the DB.
|
||||||
|
cache.store.set(CacheKey.WORKSPACE_SELF_HOSTED, ws);
|
||||||
|
cache.store.set(CacheKey.WORKSPACE_BY_HOST(hostname as string), ws);
|
||||||
|
|
||||||
|
await repo.updateSharingSettings(ws.id, 'allowInvite', true);
|
||||||
|
|
||||||
|
expect(cache.store.has(CacheKey.WORKSPACE_SELF_HOSTED)).toBe(false);
|
||||||
|
expect(cache.store.has(CacheKey.WORKSPACE_BY_HOST(hostname as string))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Generated
-3
@@ -272,9 +272,6 @@ importers:
|
|||||||
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
||||||
specifier: 1.3.4
|
specifier: 1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
'@braintree/sanitize-url':
|
|
||||||
specifier: 7.1.2
|
|
||||||
version: 7.1.2
|
|
||||||
'@casl/react':
|
'@casl/react':
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
||||||
|
|||||||
Reference in New Issue
Block a user