Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ded06fde | |||
| 456a91d289 | |||
| 515c08afed |
@@ -12,14 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Save intentional page versions.** Press `Cmd/Ctrl+S` (or use the page menu)
|
|
||||||
to save a named version of a page. The history panel now distinguishes
|
|
||||||
intentional versions (a "Saved" / "Agent version" badge) from automatic
|
|
||||||
snapshots, dims autosaves, and offers an "Only versions" filter. Automatic
|
|
||||||
snapshots switched from a fixed interval to a trailing idle-flush with a
|
|
||||||
max-wait ceiling, and a boundary snapshot is pinned whenever the editing source
|
|
||||||
changes (e.g. a person's edits followed by the AI agent). (#370)
|
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -1418,14 +1418,5 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
"Suggestion dismissed": "Suggestion dismissed",
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion",
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
"Save version": "Save version",
|
|
||||||
"Ctrl+S": "Ctrl+S",
|
|
||||||
"Version saved": "Version saved",
|
|
||||||
"Already saved as the latest version": "Already saved as the latest version",
|
|
||||||
"Agent version": "Agent version",
|
|
||||||
"Boundary": "Boundary",
|
|
||||||
"Autosave": "Autosave",
|
|
||||||
"Only versions": "Only versions",
|
|
||||||
"No saved versions yet.": "No saved versions yet."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1281,14 +1281,5 @@
|
|||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
"Dismiss": "Не применять",
|
"Dismiss": "Не применять",
|
||||||
"Suggestion dismissed": "Предложение отклонено",
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение",
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
"Save version": "Сохранить версию",
|
|
||||||
"Ctrl+S": "Ctrl+S",
|
|
||||||
"Version saved": "Версия сохранена",
|
|
||||||
"Already saved as the latest version": "Уже сохранено как последняя версия",
|
|
||||||
"Agent version": "Версия агента",
|
|
||||||
"Boundary": "Граница",
|
|
||||||
"Autosave": "Автосейв",
|
|
||||||
"Only versions": "Только версии",
|
|
||||||
"No saved versions yet.": "Пока нет сохранённых версий."
|
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-24
@@ -1,38 +1,72 @@
|
|||||||
|
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 SharedPage from "@/pages/share/shared-page.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.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";
|
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
// the page editor and the readonly share editor pull in) is fetched only when
|
||||||
import LabelPage from "@/pages/label/label-page";
|
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||||
|
// 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 />} />
|
||||||
@@ -83,6 +117,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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,9 +1,10 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { Suspense, 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 } from "jotai";
|
import { useAtom, useAtomValue } 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,
|
||||||
@@ -14,8 +15,6 @@ 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";
|
||||||
@@ -23,6 +22,21 @@ 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,
|
||||||
}: {
|
}: {
|
||||||
@@ -37,6 +51,15 @@ 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);
|
||||||
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Suspense fallback={null}>
|
||||||
|
<Aside />
|
||||||
|
</Suspense>
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||||
and self-hides when closed, so its place in the tree is not critical. */}
|
position: fixed and self-hides when closed, so its place in the tree is
|
||||||
<AiChatWindow />
|
not critical. Kept mounted after the first open so a live stream is not
|
||||||
|
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,5 +1,7 @@
|
|||||||
|
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";
|
||||||
@@ -8,10 +10,39 @@ 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>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
// 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";
|
||||||
|
|
||||||
export const pageEditorAtom = atom<Editor | null>(null);
|
export const pageEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
// #370 — the active page's collab provider, published by the page editor so the
|
|
||||||
// header menu can emit the "save-version" stateless signal (Cmd+S / button).
|
|
||||||
// Null when the page is read-only / collab isn't connected. A typed initial
|
|
||||||
// value (rather than an explicit generic) keeps jotai's overload resolution on
|
|
||||||
// the writable PrimitiveAtom branch.
|
|
||||||
const initialCollabProvider: HocuspocusProvider | null = null;
|
|
||||||
export const collabProviderAtom = atom(initialCollabProvider);
|
|
||||||
|
|
||||||
export const titleEditorAtom = atom<Editor | null>(null);
|
export const titleEditorAtom = atom<Editor | null>(null);
|
||||||
|
|
||||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block-lazy.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";
|
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||||
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,8 +1,17 @@
|
|||||||
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";
|
|
||||||
import { Editor } from "@tiptap/core";
|
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||||
|
// 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,
|
||||||
@@ -16,16 +25,19 @@ 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";
|
||||||
import {
|
// Types are erased at build time, so importing them does not pull the module's
|
||||||
|
// 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
|
||||||
@@ -58,7 +70,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === WebSocketStatus.Connected;
|
yjsStatus === YJS_STATUS_CONNECTED;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
return;
|
||||||
@@ -172,6 +184,15 @@ 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
|
||||||
|
|||||||
@@ -31,18 +31,11 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
|||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
collabProviderAtom,
|
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
dictationAvailabilityAtom,
|
dictationAvailabilityAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import {
|
|
||||||
VERSION_SAVED_MESSAGE_TYPE,
|
|
||||||
type VersionSavedMessage,
|
|
||||||
saveVersionPending,
|
|
||||||
} from "@/features/page-history/version-messages";
|
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -66,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";
|
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||||
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";
|
||||||
@@ -130,7 +123,6 @@ export default function PageEditor({
|
|||||||
|
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const [, setEditor] = useAtom(pageEditorAtom);
|
const [, setEditor] = useAtom(pageEditorAtom);
|
||||||
const setCollabProvider = useSetAtom(collabProviderAtom);
|
|
||||||
const [, setAsideState] = useAtom(asideStateAtom);
|
const [, setAsideState] = useAtom(asideStateAtom);
|
||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
@@ -188,24 +180,6 @@ export default function PageEditor({
|
|||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(payload);
|
const message = JSON.parse(payload);
|
||||||
// #370 — a version was saved somewhere; live-refresh the history panel
|
|
||||||
// on every client. Only the client that pressed Save (tracked by the
|
|
||||||
// module-level flag) shows the confirmation toast.
|
|
||||||
if (message?.type === VERSION_SAVED_MESSAGE_TYPE) {
|
|
||||||
const versionMsg = message as VersionSavedMessage;
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["page-history-list"],
|
|
||||||
});
|
|
||||||
if (saveVersionPending.current) {
|
|
||||||
saveVersionPending.current = false;
|
|
||||||
notifications.show({
|
|
||||||
message: versionMsg.alreadySaved
|
|
||||||
? t("Already saved as the latest version")
|
|
||||||
: t("Version saved"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||||
if (pageData) {
|
if (pageData) {
|
||||||
@@ -263,16 +237,12 @@ export default function PageEditor({
|
|||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
local.on("synced", onLocalSyncedHandler);
|
||||||
providersRef.current = { socket, local, remote };
|
providersRef.current = { socket, local, remote };
|
||||||
// #370 — publish the provider so the header menu can emit save-version.
|
|
||||||
setCollabProvider(remote);
|
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
} else {
|
} else {
|
||||||
setCollabProvider(providersRef.current.remote);
|
|
||||||
setProvidersReady(true);
|
setProvidersReady(true);
|
||||||
}
|
}
|
||||||
// Only destroy on final unmount
|
// Only destroy on final unmount
|
||||||
return () => {
|
return () => {
|
||||||
setCollabProvider(null);
|
|
||||||
providersRef.current?.socket.destroy();
|
providersRef.current?.socket.destroy();
|
||||||
providersRef.current?.remote.destroy();
|
providersRef.current?.remote.destroy();
|
||||||
providersRef.current?.local.destroy();
|
providersRef.current?.local.destroy();
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||||
Text,
|
|
||||||
Group,
|
|
||||||
UnstyledButton,
|
|
||||||
Avatar,
|
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
@@ -14,59 +7,36 @@ import clsx from "clsx";
|
|||||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||||
import { memo, useCallback } from "react";
|
import { memo, useCallback } from "react";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
|
|
||||||
const MAX_VISIBLE_AVATARS = 5;
|
const MAX_VISIBLE_AVATARS = 5;
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
|
|
||||||
* marks the intentional points (manual / agent); autosaves (boundary / idle /
|
|
||||||
* legacy null) are non-versions and get dimmed in the list.
|
|
||||||
*/
|
|
||||||
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
|
|
||||||
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
|
||||||
switch (kind) {
|
|
||||||
case "manual":
|
|
||||||
return { labelKey: "Saved", color: "blue", version: true };
|
|
||||||
case "agent":
|
|
||||||
return { labelKey: "Agent version", color: "violet", version: true };
|
|
||||||
case "boundary":
|
|
||||||
return { labelKey: "Boundary", color: "gray", version: false };
|
|
||||||
default: // "idle" | null | undefined (legacy autosave)
|
|
||||||
return { labelKey: "Autosave", color: "gray", version: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
historyItem: IPageHistory;
|
historyItem: IPageHistory;
|
||||||
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
index: number;
|
||||||
// in the parent (resolvePrevSnapshotId), so the item only needs to report its
|
onSelect: (id: string, index: number) => void;
|
||||||
// own id — never a list index (which would be the filtered-view index).
|
onHover?: (id: string, index: number) => void;
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onHover?: (id: string) => void;
|
|
||||||
onHoverEnd?: () => void;
|
onHoverEnd?: () => void;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryItem = memo(function HistoryItem({
|
const HistoryItem = memo(function HistoryItem({
|
||||||
historyItem,
|
historyItem,
|
||||||
|
index,
|
||||||
onSelect,
|
onSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
isActive,
|
isActive,
|
||||||
}: HistoryItemProps) {
|
}: HistoryItemProps) {
|
||||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||||
const { t } = useTranslation();
|
|
||||||
const kindMeta = historyKindMeta(historyItem.kind);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(historyItem.id);
|
onSelect(historyItem.id, index);
|
||||||
}, [onSelect, historyItem.id]);
|
}, [onSelect, historyItem.id, index]);
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
onHover?.(historyItem.id);
|
onHover?.(historyItem.id, index);
|
||||||
}, [onHover, historyItem.id]);
|
}, [onHover, historyItem.id, index]);
|
||||||
|
|
||||||
const contributors = historyItem.contributors;
|
const contributors = historyItem.contributors;
|
||||||
const hasContributors = contributors && contributors.length > 0;
|
const hasContributors = contributors && contributors.length > 0;
|
||||||
@@ -79,20 +49,8 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={onHoverEnd}
|
onMouseLeave={onHoverEnd}
|
||||||
className={clsx(classes.history, { [classes.active]: isActive })}
|
className={clsx(classes.history, { [classes.active]: isActive })}
|
||||||
// #370 — dim autosnapshots so intentional versions stand out.
|
|
||||||
style={{ opacity: kindMeta.version ? 1 : 0.55 }}
|
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
radius="sm"
|
|
||||||
variant={kindMeta.version ? "filled" : "light"}
|
|
||||||
color={kindMeta.color}
|
|
||||||
>
|
|
||||||
{t(kindMeta.labelKey)}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap={6} wrap="nowrap" mt={4}>
|
<Group gap={6} wrap="nowrap" mt={4}>
|
||||||
{hasContributors ? (
|
{hasContributors ? (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
historyAtoms,
|
historyAtoms,
|
||||||
} from "@/features/page-history/atoms/history-atoms";
|
} from "@/features/page-history/atoms/history-atoms";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -17,12 +17,9 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Switch,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistoryRestore } from "@/features/page-history/hooks";
|
import { useHistoryRestore } from "@/features/page-history/hooks";
|
||||||
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
|
|
||||||
|
|
||||||
const PREFETCH_DELAY_MS = 150;
|
const PREFETCH_DELAY_MS = 150;
|
||||||
|
|
||||||
@@ -50,23 +47,6 @@ function HistoryList({ pageId }: Props) {
|
|||||||
[pageHistoryData],
|
[pageHistoryData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy
|
|
||||||
// null), keep only intentional points (manual/agent). Filtering is over the
|
|
||||||
// already-loaded pages; the diff/restore still targets the true previous
|
|
||||||
// snapshot, so items carry their index within the FULL list.
|
|
||||||
const [onlyVersions, setOnlyVersions] = useState(false);
|
|
||||||
const isVersion = useCallback(
|
|
||||||
(kind?: string | null) => kind === "manual" || kind === "agent",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const visibleItems = useMemo(
|
|
||||||
() =>
|
|
||||||
onlyVersions
|
|
||||||
? historyItems.filter((item) => isVersion(item.kind))
|
|
||||||
: historyItems,
|
|
||||||
[historyItems, onlyVersions, isVersion],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -80,13 +60,11 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleHover = useCallback(
|
const handleHover = useCallback(
|
||||||
(historyId: string) => {
|
(historyId: string, index: number) => {
|
||||||
clearPrefetchTimeout();
|
clearPrefetchTimeout();
|
||||||
prefetchTimeoutRef.current = setTimeout(() => {
|
prefetchTimeoutRef.current = setTimeout(() => {
|
||||||
prefetchPageHistory(historyId);
|
prefetchPageHistory(historyId);
|
||||||
// The true previous snapshot in the FULL list (not the previous visible
|
const prevId = historyItems[index + 1]?.id;
|
||||||
// one under the "only versions" filter).
|
|
||||||
const prevId = resolvePrevSnapshotId(historyItems, historyId);
|
|
||||||
if (prevId) {
|
if (prevId) {
|
||||||
prefetchPageHistory(prevId);
|
prefetchPageHistory(prevId);
|
||||||
}
|
}
|
||||||
@@ -100,11 +78,9 @@ function HistoryList({ pageId }: Props) {
|
|||||||
}, [clearPrefetchTimeout]);
|
}, [clearPrefetchTimeout]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string, index: number) => {
|
||||||
setActiveHistoryId(id);
|
setActiveHistoryId(id);
|
||||||
// Baseline = true previous snapshot in the FULL list, so the "only
|
setActiveHistoryPrevId(historyItems[index + 1]?.id ?? "");
|
||||||
// versions" filter never diffs/restores against the wrong item.
|
|
||||||
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
|
|
||||||
},
|
},
|
||||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||||
);
|
);
|
||||||
@@ -152,27 +128,12 @@ function HistoryList({ pageId }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Group px="xs" py={6} justify="flex-end">
|
|
||||||
<Switch
|
|
||||||
size="xs"
|
|
||||||
checked={onlyVersions}
|
|
||||||
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
|
||||||
label={t("Only versions")}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||||
{onlyVersions && visibleItems.length === 0 && (
|
{historyItems.map((historyItem, index) => (
|
||||||
<Center py="md">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("No saved versions yet.")}
|
|
||||||
</Text>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
{visibleItems.map((historyItem) => (
|
|
||||||
<HistoryItem
|
<HistoryItem
|
||||||
key={historyItem.id}
|
key={historyItem.id}
|
||||||
historyItem={historyItem}
|
historyItem={historyItem}
|
||||||
|
index={index}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleHover}
|
onHover={handleHover}
|
||||||
onHoverEnd={clearPrefetchTimeout}
|
onHoverEnd={clearPrefetchTimeout}
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ export interface IPageHistory {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
lastUpdatedBy: IPageHistoryUser;
|
lastUpdatedBy: IPageHistoryUser;
|
||||||
contributors?: IPageHistoryUser[];
|
contributors?: IPageHistoryUser[];
|
||||||
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
|
|
||||||
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
|
|
||||||
// autosave. Derived server-side, drives the history badge + "versions" filter.
|
|
||||||
kind?: "manual" | "agent" | "idle" | "boundary" | null;
|
|
||||||
// Provenance markers copied off the page row when the snapshot was saved.
|
// Provenance markers copied off the page row when the snapshot was saved.
|
||||||
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
||||||
// (when present) deep-links to the chat that produced the edit.
|
// (when present) deep-links to the chat that produced the edit.
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { resolvePrevSnapshotId } from "./resolve-prev-snapshot";
|
|
||||||
|
|
||||||
// #370 F4 — the risky client path: with the "only versions" filter active, diff
|
|
||||||
// and restore must still baseline against the TRUE previous snapshot in the FULL
|
|
||||||
// list, never the previous VISIBLE version (which would skip the autosnapshots
|
|
||||||
// between two versions). These pin that the resolution is by FULL-list order.
|
|
||||||
describe("resolvePrevSnapshotId", () => {
|
|
||||||
// Newest-first, as the history list stores it: a version, then two autosaves,
|
|
||||||
// then an older version.
|
|
||||||
const full = [
|
|
||||||
{ id: "v2", kind: "manual" },
|
|
||||||
{ id: "a2", kind: "idle" },
|
|
||||||
{ id: "a1", kind: "boundary" },
|
|
||||||
{ id: "v1", kind: "manual" },
|
|
||||||
{ id: "a0", kind: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("returns the immediate FULL-list successor, not the previous visible version", () => {
|
|
||||||
// Selecting v2 while filtered to versions-only must baseline against a2 (the
|
|
||||||
// real chronological predecessor), NOT v1 (the previous visible version).
|
|
||||||
expect(resolvePrevSnapshotId(full, "v2")).toBe("a2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves an autosnapshot's predecessor by full-list order", () => {
|
|
||||||
expect(resolvePrevSnapshotId(full, "a1")).toBe("v1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '' for the oldest item (no predecessor)", () => {
|
|
||||||
expect(resolvePrevSnapshotId(full, "a0")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns '' for an id not in the list", () => {
|
|
||||||
expect(resolvePrevSnapshotId(full, "missing")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not depend on a filtered subset — same result whatever is visible", () => {
|
|
||||||
// The helper only ever sees the full list; a filtered view cannot change the
|
|
||||||
// baseline it computes.
|
|
||||||
expect(resolvePrevSnapshotId(full, "v1")).toBe("a0");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* #370 — resolve the TRUE previous snapshot for a history item.
|
|
||||||
*
|
|
||||||
* The history panel can be filtered to "only versions" (manual/agent), but diff
|
|
||||||
* and restore must always compare against the immediately-preceding snapshot in
|
|
||||||
* the FULL, unfiltered list — NOT the previous VISIBLE item. Comparing against
|
|
||||||
* the previous visible version would silently skip the autosnapshots between two
|
|
||||||
* versions and diff/restore the wrong baseline.
|
|
||||||
*
|
|
||||||
* Given the full (newest-first) list and an item id, this returns the id of the
|
|
||||||
* item right after it in the full list (its chronological predecessor), or "" if
|
|
||||||
* it is the oldest / not found. Pure and list-order-preserving so it can be unit
|
|
||||||
* tested without mounting the component.
|
|
||||||
*/
|
|
||||||
export function resolvePrevSnapshotId(
|
|
||||||
fullItems: ReadonlyArray<{ id: string }>,
|
|
||||||
id: string,
|
|
||||||
): string {
|
|
||||||
const index = fullItems.findIndex((item) => item.id === id);
|
|
||||||
if (index === -1) return "";
|
|
||||||
return fullItems[index + 1]?.id ?? "";
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* #370 — page-version stateless wire formats. Kept in one place so the client
|
|
||||||
* emitter (Save hotkey / button) and the client listener (page-editor) agree
|
|
||||||
* with the server (PersistenceExtension) on the message shapes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Client → server: "save a version now". The server derives the tier
|
|
||||||
* (manual/agent) from the signed connection actor, never from this payload. */
|
|
||||||
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
|
||||||
|
|
||||||
/** Server → all clients: a version was saved (or promoted / already existed). */
|
|
||||||
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
|
||||||
|
|
||||||
export interface VersionSavedMessage {
|
|
||||||
type: typeof VERSION_SAVED_MESSAGE_TYPE;
|
|
||||||
historyId: string;
|
|
||||||
kind: "manual" | "agent";
|
|
||||||
/** True when the latest snapshot was already a manual version (a no-op save). */
|
|
||||||
alreadySaved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-component coordination flag so only the client that pressed Save shows
|
|
||||||
* the confirmation toast, while every other client silently refreshes its
|
|
||||||
* history panel on the broadcast. A module-level ref avoids stale-closure
|
|
||||||
* pitfalls in the editor's long-lived stateless handler.
|
|
||||||
*/
|
|
||||||
export const saveVersionPending = { current: false };
|
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconDeviceFloppy,
|
|
||||||
IconDots,
|
IconDots,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconWifiOff,
|
IconWifiOff,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||||
@@ -40,14 +39,9 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
collabProviderAtom,
|
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import {
|
|
||||||
SAVE_VERSION_MESSAGE_TYPE,
|
|
||||||
saveVersionPending,
|
|
||||||
} from "@/features/page-history/version-messages.ts";
|
|
||||||
import { formattedDate } from "@/lib/time.ts";
|
import { formattedDate } from "@/lib/time.ts";
|
||||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
@@ -78,34 +72,9 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
});
|
});
|
||||||
const isDeleted = !!page?.deletedAt;
|
const isDeleted = !!page?.deletedAt;
|
||||||
const [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
const collabProvider = useAtomValue(collabProviderAtom);
|
|
||||||
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
||||||
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||||
|
|
||||||
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
|
|
||||||
// human; the server derives the tier from the signed actor. Readers can't save
|
|
||||||
// (the button is hidden and the collab connection is read-only server-side).
|
|
||||||
const handleSaveVersion = useCallback(() => {
|
|
||||||
if (readOnly || !collabProvider) return;
|
|
||||||
// Flag this client as the initiator so only it shows the confirmation toast;
|
|
||||||
// a safety timeout clears it if no broadcast comes back (e.g. offline).
|
|
||||||
saveVersionPending.current = true;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
saveVersionPending.current = false;
|
|
||||||
}, 5000);
|
|
||||||
collabProvider.sendStateless(
|
|
||||||
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
|
||||||
);
|
|
||||||
}, [readOnly, collabProvider]);
|
|
||||||
|
|
||||||
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
|
|
||||||
// Editable` + empty ignore-list so it fires while typing in the editor/title.
|
|
||||||
useHotkeys(
|
|
||||||
[["mod+S", handleSaveVersion, { preventDefault: true }]],
|
|
||||||
[],
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
@@ -164,16 +133,15 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
|
<PageActionMenu readOnly={readOnly} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageActionMenuProps {
|
interface PageActionMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onSaveVersion?: () => void;
|
|
||||||
}
|
}
|
||||||
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||||
const clipboard = useClipboard({ timeout: 500 });
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
@@ -334,20 +302,6 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconDeviceFloppy size={16} />}
|
|
||||||
onClick={onSaveVersion}
|
|
||||||
rightSection={
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("Ctrl+S")}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("Save version")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconHistory size={16} />}
|
leftSection={<IconHistory size={16} />}
|
||||||
onClick={openHistoryModal}
|
onClick={openHistoryModal}
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
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>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
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 "@docmost/editor-ext";
|
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||||
|
|
||||||
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 "@docmost/editor-ext";
|
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
+60
-27
@@ -13,15 +13,14 @@ 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({
|
||||||
@@ -35,15 +34,6 @@ 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();
|
||||||
@@ -51,19 +41,62 @@ 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);
|
||||||
|
|
||||||
root.render(
|
function renderApp() {
|
||||||
<BrowserRouter>
|
root.render(
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<BrowserRouter>
|
||||||
<ModalsProvider>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ModalsProvider>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<QueryClientProvider client={queryClient}>
|
||||||
<HelmetProvider>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<PostHogProvider client={posthog}>
|
<HelmetProvider>
|
||||||
<App />
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
</PostHogProvider>
|
404 after a deploy is caught and recovered here instead of
|
||||||
</HelmetProvider>
|
blanking the whole app. */}
|
||||||
</QueryClientProvider>
|
<ChunkLoadErrorBoundary>
|
||||||
</ModalsProvider>
|
<App />
|
||||||
</MantineProvider>
|
</ChunkLoadErrorBoundary>
|
||||||
</BrowserRouter>,
|
</HelmetProvider>
|
||||||
);
|
</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,6 +63,20 @@ 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,29 +1,3 @@
|
|||||||
/**
|
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||||
* #370 — page-history intentionality tiers. Domain of `page_history.kind`.
|
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||||
* - 'manual' / 'agent' → Tier 1 versions (intentional points)
|
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||||
* - 'idle' / 'boundary' → Tier 0 autosnapshots (safety net)
|
|
||||||
* A legacy `null` kind is treated as an autosave.
|
|
||||||
*/
|
|
||||||
export type PageHistoryKind = 'manual' | 'agent' | 'idle' | 'boundary';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — trailing idle-flush windows. A page's pending idle snapshot is
|
|
||||||
* re-armed on every store and fires this long after edits go quiet, so a burst
|
|
||||||
* of edits collapses into a single autosnapshot instead of one-per-store. Human
|
|
||||||
* sessions are noisier and less risky, so they flush less often than the agent.
|
|
||||||
*/
|
|
||||||
export const IDLE_INTERVAL_USER = 60 * 60 * 1000; // 60m
|
|
||||||
export const IDLE_INTERVAL_AGENT = 15 * 60 * 1000; // 15m
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — max-wait ceiling for the idle flush. Pure trailing debounce starves the
|
|
||||||
* safety net: hocuspocus stores at least every ~45s, so a CONTINUOUS editing
|
|
||||||
* session would re-arm the trailing timer forever and never take an idle
|
|
||||||
* snapshot until edits finally go quiet (up to IDLE_INTERVAL_USER = 60m). This
|
|
||||||
* ceiling bounds the actual wait from the FIRST edit of a burst, so an idle
|
|
||||||
* snapshot fires at least this often during a long unbroken session — restoring
|
|
||||||
* a recovery point cadence closer to the old heuristic without one-per-store
|
|
||||||
* noise. Mirrors hocuspocus's own maxDebounce idea.
|
|
||||||
*/
|
|
||||||
export const IDLE_MAX_WAIT_USER = 10 * 60 * 1000; // 10m
|
|
||||||
export const IDLE_MAX_WAIT_AGENT = 5 * 60 * 1000; // 5m
|
|
||||||
|
|||||||
@@ -1,93 +1,84 @@
|
|||||||
import { computeHistoryJob, resolveSource } from './persistence.extension';
|
|
||||||
import {
|
import {
|
||||||
IDLE_INTERVAL_AGENT,
|
computeHistoryJob,
|
||||||
IDLE_INTERVAL_USER,
|
resolveSource,
|
||||||
IDLE_MAX_WAIT_AGENT,
|
} from './persistence.extension';
|
||||||
IDLE_MAX_WAIT_USER,
|
import {
|
||||||
|
HISTORY_FAST_INTERVAL,
|
||||||
|
HISTORY_FAST_THRESHOLD,
|
||||||
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
|
|
||||||
|
// A fixed clock + fixed createdAt make pageAge deterministic.
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
const page = { id: PAGE_ID };
|
// Build a minimal page whose age (NOW - createdAt) is exactly `ageMs`.
|
||||||
|
const pageAged = (ageMs: number) => ({
|
||||||
|
id: PAGE_ID,
|
||||||
|
createdAt: new Date(NOW - ageMs),
|
||||||
|
});
|
||||||
|
|
||||||
describe('computeHistoryJob (#370 — shared trailing idle pipeline)', () => {
|
describe('computeHistoryJob', () => {
|
||||||
it('human edit → user idle window, bare page.id job', () => {
|
it('agent edit → delay MUST be 0 and job id is source-keyed', () => {
|
||||||
// Humans and the agent now share ONE idle job per page (jobId = page.id).
|
// INVARIANT (§15 H2 / persistence.extension): the agent delay MUST stay 0.
|
||||||
// The agent's old delay=0 fast path is GONE — intentional agent points now
|
// The worker re-reads the page row at run time, so any non-zero delay risks
|
||||||
// arrive via the explicit save-version signal, not a zero-delay snapshot.
|
// snapshotting content a later human edit has already overwritten. This is
|
||||||
const { jobId, delay } = computeHistoryJob(page, 'user');
|
// the load-bearing assertion of this spec — do not relax it.
|
||||||
expect(delay).toBe(IDLE_INTERVAL_USER);
|
const { jobId, delay } = computeHistoryJob(pageAged(0), 'agent', NOW);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent edit on an OLD page is still delay 0 (age never applies to agents)', () => {
|
||||||
|
// Even when the page is far older than the fast threshold, the agent path
|
||||||
|
// must short-circuit to 0 — age-based debounce is a human-only concern.
|
||||||
|
const { jobId, delay } = computeHistoryJob(
|
||||||
|
pageAged(HISTORY_FAST_THRESHOLD + 60_000),
|
||||||
|
'agent',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(0);
|
||||||
|
expect(jobId).toBe(`${PAGE_ID}-agent`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('human edit on a YOUNG page (age < threshold) → fast interval, bare job id', () => {
|
||||||
|
const { jobId, delay } = computeHistoryJob(
|
||||||
|
pageAged(HISTORY_FAST_THRESHOLD - 1),
|
||||||
|
'user',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('agent edit → agent idle window (shorter), still the bare page.id job', () => {
|
it('human edit on an OLD page (age > threshold) → standard interval', () => {
|
||||||
const { jobId, delay } = computeHistoryJob(page, 'agent');
|
const { jobId, delay } = computeHistoryJob(
|
||||||
expect(delay).toBe(IDLE_INTERVAL_AGENT);
|
pageAged(HISTORY_FAST_THRESHOLD + 1),
|
||||||
// No `-agent` suffix anymore: the agent joins the common idle pipeline.
|
'user',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(HISTORY_INTERVAL);
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('agent flushes sooner than a human', () => {
|
it('boundary: pageAge EXACTLY === threshold takes the slow branch (the `<` is strict)', () => {
|
||||||
expect(IDLE_INTERVAL_AGENT).toBeLessThan(IDLE_INTERVAL_USER);
|
// Off-by-one guard: the condition is `pageAge < HISTORY_FAST_THRESHOLD`, so
|
||||||
|
// an age of exactly the threshold is NOT "fast" — it must use HISTORY_INTERVAL.
|
||||||
|
const { delay } = computeHistoryJob(
|
||||||
|
pageAged(HISTORY_FAST_THRESHOLD),
|
||||||
|
'user',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
expect(delay).toBe(HISTORY_INTERVAL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats any non-"agent" source string as human (keys strictly on === agent)', () => {
|
it('treats any non-"agent" source string as human', () => {
|
||||||
const { jobId, delay } = computeHistoryJob(page, 'user');
|
// resolveSource only ever yields 'agent' | 'user', but guard the contract:
|
||||||
expect(delay).toBe(IDLE_INTERVAL_USER);
|
// the agent branch keys strictly on === 'agent'.
|
||||||
|
const { jobId, delay } = computeHistoryJob(pageAged(0), 'user', NOW);
|
||||||
|
expect(delay).toBe(HISTORY_FAST_INTERVAL);
|
||||||
expect(jobId).toBe(PAGE_ID);
|
expect(jobId).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 review round-1 WARNING: the max-wait ceiling prevents autosnapshot
|
|
||||||
// starvation during a continuous editing session (the trailing timer would
|
|
||||||
// otherwise re-arm forever and never fire).
|
|
||||||
describe('max-wait ceiling', () => {
|
|
||||||
const T0 = 1_000_000; // arbitrary fixed epoch for deterministic tests
|
|
||||||
|
|
||||||
it('once a burst is armed, delay clamps to the remaining max-wait budget', () => {
|
|
||||||
// 1 minute into the burst the USER interval (60m) far exceeds the remaining
|
|
||||||
// max-wait budget (10m - 1m = 9m), so the delay is clamped DOWN to that
|
|
||||||
// remaining budget — the full interval is NOT used once a ceiling applies.
|
|
||||||
const { delay } = computeHistoryJob(page, 'user', T0, T0 + 60_000);
|
|
||||||
expect(delay).toBe(IDLE_MAX_WAIT_USER - 60_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never waits longer than the max-wait budget from the burst start', () => {
|
|
||||||
// A store arriving right at the ceiling → delay 0 (fire promptly).
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
page,
|
|
||||||
'user',
|
|
||||||
T0,
|
|
||||||
T0 + IDLE_MAX_WAIT_USER,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('past the ceiling never returns a negative delay', () => {
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
page,
|
|
||||||
'user',
|
|
||||||
T0,
|
|
||||||
T0 + IDLE_MAX_WAIT_USER + 5 * 60_000,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the agent ceiling is shorter than the user ceiling', () => {
|
|
||||||
expect(IDLE_MAX_WAIT_AGENT).toBeLessThan(IDLE_MAX_WAIT_USER);
|
|
||||||
const { delay } = computeHistoryJob(
|
|
||||||
page,
|
|
||||||
'agent',
|
|
||||||
T0,
|
|
||||||
T0 + IDLE_MAX_WAIT_AGENT,
|
|
||||||
);
|
|
||||||
expect(delay).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('without a burstStart there is no ceiling (backward-compatible)', () => {
|
|
||||||
expect(computeHistoryJob(page, 'user').delay).toBe(IDLE_INTERVAL_USER);
|
|
||||||
expect(computeHistoryJob(page, 'agent').delay).toBe(IDLE_INTERVAL_AGENT);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveSource (truth table)', () => {
|
describe('resolveSource (truth table)', () => {
|
||||||
|
|||||||
@@ -40,12 +40,11 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
let pageHistoryRepo: {
|
let pageHistoryRepo: {
|
||||||
saveHistory: jest.Mock;
|
saveHistory: jest.Mock;
|
||||||
findPageLastHistory: jest.Mock;
|
findPageLastHistory: jest.Mock;
|
||||||
updateHistoryKind: jest.Mock;
|
|
||||||
};
|
};
|
||||||
let aiQueue: { add: jest.Mock };
|
let aiQueue: { add: jest.Mock };
|
||||||
let historyQueue: { add: jest.Mock; remove: jest.Mock };
|
let historyQueue: { add: jest.Mock };
|
||||||
let notificationQueue: { add: jest.Mock };
|
let notificationQueue: { add: jest.Mock };
|
||||||
let collabHistory: { addContributors: jest.Mock; popContributors: jest.Mock };
|
let collabHistory: { addContributors: jest.Mock };
|
||||||
let transclusionService: {
|
let transclusionService: {
|
||||||
syncPageTransclusions: jest.Mock;
|
syncPageTransclusions: jest.Mock;
|
||||||
syncPageReferences: jest.Mock;
|
syncPageReferences: jest.Mock;
|
||||||
@@ -94,22 +93,13 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
pageHistoryRepo = {
|
pageHistoryRepo = {
|
||||||
saveHistory: jest.fn().mockImplementation(async () => {
|
saveHistory: jest.fn().mockImplementation(async () => {
|
||||||
callOrder.push('saveHistory');
|
callOrder.push('saveHistory');
|
||||||
return { id: 'history-1' };
|
|
||||||
}),
|
}),
|
||||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||||
updateHistoryKind: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
};
|
||||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
historyQueue = {
|
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
add: jest.fn().mockResolvedValue(undefined),
|
|
||||||
// #370 — enqueuePageHistory now removes any pending idle job before re-adding.
|
|
||||||
remove: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
collabHistory = {
|
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||||
addContributors: jest.fn().mockResolvedValue(undefined),
|
|
||||||
popContributors: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
transclusionService = {
|
transclusionService = {
|
||||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -175,50 +165,6 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 review round-1 SUGGESTION: the boundary was GENERALIZED from a
|
|
||||||
// user→agent special-case to ANY lastUpdatedSource transition. These pin the
|
|
||||||
// generalized behaviour it was rebuilt for.
|
|
||||||
describe('generalized boundary — any source transition', () => {
|
|
||||||
// Same persisted page but with an explicit prior source.
|
|
||||||
const pageWithPriorSource = (prior: string | null) => ({
|
|
||||||
...persistedHumanPage('NEW CONTENT'),
|
|
||||||
lastUpdatedSource: prior,
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent→user transition fires the boundary (pins the prior agent revision)', async () => {
|
|
||||||
const document = ydocFor(doc('NEW CONTENT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageWithPriorSource('agent'));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
|
||||||
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
|
||||||
expect(pageRepo.updatePage.mock.calls[0][0].lastUpdatedSource).toBe('user');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('git→user transition fires the boundary (git-sync overwrite is a source change)', async () => {
|
|
||||||
const document = ydocFor(doc('NEW CONTENT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageWithPriorSource('git'));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
|
||||||
expect(callOrder).toEqual(['saveHistory', 'updatePage']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a null prior source (first-ever edit) does NOT fire the boundary', async () => {
|
|
||||||
const document = ydocFor(doc('NEW CONTENT'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageWithPriorSource(null));
|
|
||||||
|
|
||||||
await ext.onStoreDocument(buildData(document, 'agent') as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
it('idempotency: unchanged content → no updatePage, no history, no queues', async () => {
|
||||||
// The Y.Doc content equals the persisted content deeply → early skip.
|
// The Y.Doc content equals the persisted content deeply → early skip.
|
||||||
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
// A Y.Doc round-trip normalizes attrs (e.g. paragraph indent), so derive
|
||||||
@@ -523,125 +469,4 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
|||||||
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
// Contributors keyed by the UUID so they match the PAGE_HISTORY job (page.id).
|
||||||
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
expect(collabHistory.addContributors.mock.calls[0][0]).toBe(PAGE_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #370 — explicit save-version (Cmd+S / agent save tool) over the stateless
|
|
||||||
// seam. The tier is derived from the SIGNED connection actor, the store path
|
|
||||||
// is reused, and promote-not-dup avoids duplicating heavy content rows.
|
|
||||||
describe('save-version (#370)', () => {
|
|
||||||
const emitSave = (document: any, actor: 'user' | 'agent') =>
|
|
||||||
ext.onStateless({
|
|
||||||
connection: {
|
|
||||||
readOnly: false,
|
|
||||||
context: { user: { id: USER_ID, name: 'Alice' }, actor },
|
|
||||||
} as any,
|
|
||||||
documentName: `page.${PAGE_ID}`,
|
|
||||||
document: document as any,
|
|
||||||
payload: JSON.stringify({ type: 'save-version' }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// findById returns a page whose content already equals the live doc, so the
|
|
||||||
// store path is a no-op and we isolate the versioning decision.
|
|
||||||
const pageMatchingDoc = (document: any) => ({
|
|
||||||
...persistedHumanPage('IGNORED'),
|
|
||||||
content: TiptapTransformer.fromYdoc(document, 'default'),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('human save with no prior snapshot → writes a manual version + broadcasts', async () => {
|
|
||||||
const document = ydocFor(doc('VERSION ME'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await emitSave(document, 'user');
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledTimes(1);
|
|
||||||
expect(pageHistoryRepo.saveHistory.mock.calls[0][1]).toEqual(
|
|
||||||
expect.objectContaining({ kind: 'manual' }),
|
|
||||||
);
|
|
||||||
// The pending idle autosnapshot is cancelled by the explicit version.
|
|
||||||
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
|
||||||
const msg = JSON.parse(
|
|
||||||
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
|
||||||
);
|
|
||||||
expect(msg).toMatchObject({
|
|
||||||
type: 'version.saved',
|
|
||||||
kind: 'manual',
|
|
||||||
alreadySaved: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent save derives kind=agent from the signed actor', async () => {
|
|
||||||
const document = ydocFor(doc('AGENT VERSION'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await emitSave(document, 'agent');
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory.mock.calls[pageHistoryRepo.saveHistory.mock.calls.length - 1][1]).toEqual(
|
|
||||||
expect.objectContaining({ kind: 'agent' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('promote-not-dup: latest snapshot is an autosave with identical content → upgrades in place', async () => {
|
|
||||||
const document = ydocFor(doc('SAME'));
|
|
||||||
const page = pageMatchingDoc(document);
|
|
||||||
pageRepo.findById.mockResolvedValue(page);
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
|
||||||
id: 'auto-1',
|
|
||||||
content: page.content,
|
|
||||||
kind: 'idle',
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitSave(document, 'user');
|
|
||||||
|
|
||||||
// No heavy new content row — the existing autosave is promoted to manual.
|
|
||||||
expect(pageHistoryRepo.updateHistoryKind).toHaveBeenCalledWith(
|
|
||||||
'auto-1',
|
|
||||||
'manual',
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
const msg = JSON.parse(
|
|
||||||
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
|
||||||
);
|
|
||||||
expect(msg).toMatchObject({ historyId: 'auto-1', alreadySaved: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no-op when the latest snapshot is already a manual version of this content', async () => {
|
|
||||||
const document = ydocFor(doc('ALREADY SAVED'));
|
|
||||||
const page = pageMatchingDoc(document);
|
|
||||||
pageRepo.findById.mockResolvedValue(page);
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
|
||||||
id: 'ver-1',
|
|
||||||
content: page.content,
|
|
||||||
kind: 'manual',
|
|
||||||
});
|
|
||||||
|
|
||||||
await emitSave(document, 'user');
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
const msg = JSON.parse(
|
|
||||||
(document as any).broadcastStateless.mock.calls[(document as any).broadcastStateless.mock.calls.length - 1][0],
|
|
||||||
);
|
|
||||||
expect(msg).toMatchObject({ alreadySaved: true, kind: 'manual' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a read-only connection cannot save a version', async () => {
|
|
||||||
const document = ydocFor(doc('READER'));
|
|
||||||
pageRepo.findById.mockResolvedValue(pageMatchingDoc(document));
|
|
||||||
|
|
||||||
await ext.onStateless({
|
|
||||||
connection: {
|
|
||||||
readOnly: true,
|
|
||||||
context: { user: { id: USER_ID }, actor: 'user' },
|
|
||||||
} as any,
|
|
||||||
documentName: `page.${PAGE_ID}`,
|
|
||||||
document: document as any,
|
|
||||||
payload: JSON.stringify({ type: 'save-version' }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
|
||||||
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,11 +36,9 @@ 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 {
|
||||||
IDLE_INTERVAL_AGENT,
|
HISTORY_FAST_INTERVAL,
|
||||||
IDLE_INTERVAL_USER,
|
HISTORY_FAST_THRESHOLD,
|
||||||
IDLE_MAX_WAIT_AGENT,
|
HISTORY_INTERVAL,
|
||||||
IDLE_MAX_WAIT_USER,
|
|
||||||
PageHistoryKind,
|
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
||||||
@@ -53,16 +51,6 @@ import { observeCollabStore } from '../../integrations/metrics/metrics.registry'
|
|||||||
*/
|
*/
|
||||||
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — wire format of the client→server "save a version" signal. Sent by the
|
|
||||||
* human (Cmd+S / Save button) and by the agent's explicit save tool over the
|
|
||||||
* SAME stateless channel. The intentionality tier ('manual' vs 'agent') is
|
|
||||||
* derived SERVER-SIDE from the signed connection actor, never from this
|
|
||||||
* payload, so a version's type is unforgeable. The document is taken from the
|
|
||||||
* connection (not the payload), so the signal cannot be aimed at another page.
|
|
||||||
*/
|
|
||||||
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||||
@@ -99,39 +87,35 @@ export function resolveSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #370 — compute the BullMQ job id + delay for a page's trailing idle-flush
|
* Compute the BullMQ job id + delay for a page-history snapshot job. Pure so
|
||||||
* autosnapshot. Pure so the timing is unit-testable.
|
* the data-loss-sensitive timing arithmetic is unit-testable; `now` is injected
|
||||||
|
* (caller passes `Date.now()`) for determinism.
|
||||||
*
|
*
|
||||||
* Both humans and the agent now share ONE idle pipeline (the agent's old
|
* - Agent edits: delay 0 and a source-keyed job id `${page.id}-agent`. The
|
||||||
* `delay=0` fast path is gone — intentional agent points arrive via the
|
* delay MUST stay 0 — the worker re-reads the page row at run time, so any
|
||||||
* explicit save-version signal instead). The job id is the bare `page.id`, so a
|
* delay risks reading content a later human edit has already overwritten
|
||||||
* page has at most one pending idle job; the caller removes-and-re-adds it on
|
* (mis-tagged snapshot). 0 minimizes that window. The `-agent` suffix keeps
|
||||||
* every store to keep it debounced to the trailing edge of an edit burst. The
|
* the job from coalescing with the bare-page.id human job.
|
||||||
* window differs by source only: the agent flushes sooner than a human.
|
* - Human edits: age-based debounce so rapid human edits coalesce into one
|
||||||
|
* snapshot; job id is the bare `page.id`.
|
||||||
|
*
|
||||||
|
* BullMQ forbids ':' in custom job ids (Redis key separator), so '-' is used;
|
||||||
|
* page.id is a UUID, so `${page.id}-agent` cannot collide with a human job.
|
||||||
*/
|
*/
|
||||||
export function computeHistoryJob(
|
export function computeHistoryJob(
|
||||||
page: Pick<Page, 'id'>,
|
page: Pick<Page, 'id' | 'createdAt'>,
|
||||||
source: string,
|
source: string,
|
||||||
// Epoch ms of the FIRST edit in the current burst (when the pending idle job
|
now: number,
|
||||||
// was first armed). Used to enforce the max-wait ceiling so a continuous
|
|
||||||
// editing session cannot re-arm the trailing timer forever. `now` is injectable
|
|
||||||
// for tests; both default to a live clock / no ceiling when omitted.
|
|
||||||
burstStart?: number,
|
|
||||||
now: number = Date.now(),
|
|
||||||
): { jobId: string; delay: number } {
|
): { jobId: string; delay: number } {
|
||||||
const isAgent = source === 'agent';
|
const isAgent = source === 'agent';
|
||||||
const interval = isAgent ? IDLE_INTERVAL_AGENT : IDLE_INTERVAL_USER;
|
const pageAge = now - new Date(page.createdAt).getTime();
|
||||||
const maxWait = isAgent ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
|
const delay = isAgent
|
||||||
|
? 0
|
||||||
let delay = interval;
|
: pageAge < HISTORY_FAST_THRESHOLD
|
||||||
if (burstStart !== undefined) {
|
? HISTORY_FAST_INTERVAL
|
||||||
// Time already elapsed since the burst's first edit; the snapshot must fire
|
: HISTORY_INTERVAL;
|
||||||
// no later than `maxWait` after that, so shrink the trailing delay to the
|
const jobId = isAgent ? `${page.id}-agent` : page.id;
|
||||||
// remaining budget (never negative, so BullMQ fires it promptly).
|
return { jobId, delay };
|
||||||
const remaining = burstStart + maxWait - now;
|
|
||||||
delay = Math.max(0, Math.min(interval, remaining));
|
|
||||||
}
|
|
||||||
return { jobId: page.id, delay };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -143,11 +127,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
// coalescing window" per document and OR it across all edits in the window,
|
// coalescing window" per document and OR it across all edits in the window,
|
||||||
// so the snapshot is marked 'agent' regardless of who wrote last.
|
// so the snapshot is marked 'agent' regardless of who wrote last.
|
||||||
private agentTouched: Map<string, boolean> = new Map();
|
private agentTouched: Map<string, boolean> = new Map();
|
||||||
// #370 — epoch ms of the FIRST edit in the current idle-flush burst, per page.
|
|
||||||
// Set when the pending idle job is first armed (empty entry), read to enforce
|
|
||||||
// the max-wait ceiling in computeHistoryJob, and cleared when the idle job is
|
|
||||||
// consumed/cancelled so the next burst starts a fresh window.
|
|
||||||
private idleBurstStart: Map<string, number> = new Map();
|
|
||||||
// #251 — per-document "intentional clear pending" flags. Keyed by
|
// #251 — per-document "intentional clear pending" flags. Keyed by
|
||||||
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
// documentName, value = expiry timestamp (ms). Set by onStateless when the
|
||||||
// client reports a deliberate clear; consumed once by the next
|
// client reports a deliberate clear; consumed once by the next
|
||||||
@@ -347,19 +326,20 @@ export class PersistenceExtension implements Extension {
|
|||||||
//this.logger.debug('Contributors error:' + err?.['message']);
|
//this.logger.debug('Contributors error:' + err?.['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #370 — boundary snapshot on ANY source transition. When the store
|
// Approach A — boundary snapshot before the agent's first edit.
|
||||||
// flips the page's provenance (user↔agent↔git), pin the OUTGOING
|
// When this store is the agent's and the page's currently persisted
|
||||||
// state as its own history version BEFORE the incoming source
|
// state was authored by a human, pin that human state as its own
|
||||||
// overwrites it. `page` still holds the OLD content/provenance here,
|
// history version BEFORE the agent overwrites it. `page` still holds
|
||||||
// so saveHistory(page) captures the pre-transition state tagged with
|
// the OLD content/provenance here, so saveHistory(page) captures the
|
||||||
// its own source, kind='boundary'. The incoming content is snapshotted
|
// pre-agent state tagged 'user'. The agent's new content is
|
||||||
// later by the debounced idle job. Skip if the page is effectively
|
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
||||||
// empty or if the latest existing snapshot already equals this state
|
// if the prior state is already agent-authored (boundary already
|
||||||
// (the shared isDeepStrictEqual gate — avoids duplicates). Generalizing
|
// pinned on the user->agent transition), if the page is effectively
|
||||||
// beyond the old user→agent special-case also covers git-sync for free.
|
// empty, or if the latest existing snapshot already equals this human
|
||||||
|
// state (avoid duplicates).
|
||||||
if (
|
if (
|
||||||
page.lastUpdatedSource &&
|
lastUpdatedSource === 'agent' &&
|
||||||
page.lastUpdatedSource !== lastUpdatedSource
|
page.lastUpdatedSource !== 'agent'
|
||||||
) {
|
) {
|
||||||
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
// pageHistory.pageId is uuid-typed; use page.id (never the doc-name
|
||||||
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
// slugId) so a `page.<slugId>` doc cannot throw 22P02 here (#260).
|
||||||
@@ -367,13 +347,15 @@ export class PersistenceExtension implements Extension {
|
|||||||
page.id,
|
page.id,
|
||||||
{ includeContent: true, trx },
|
{ includeContent: true, trx },
|
||||||
);
|
);
|
||||||
const baselineMissing =
|
const humanBaselineMissing =
|
||||||
!lastHistory ||
|
!lastHistory ||
|
||||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
!isDeepStrictEqual(lastHistory.content, page.content);
|
||||||
if (!isEmptyParagraphDoc(page.content as any) && baselineMissing) {
|
if (
|
||||||
|
!isEmptyParagraphDoc(page.content as any) &&
|
||||||
|
humanBaselineMissing
|
||||||
|
) {
|
||||||
await this.pageHistoryRepo.saveHistory(page, {
|
await this.pageHistoryRepo.saveHistory(page, {
|
||||||
contributorIds: page.contributorIds ?? undefined,
|
contributorIds: page.contributorIds ?? undefined,
|
||||||
kind: 'boundary',
|
|
||||||
trx,
|
trx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -498,14 +480,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
return; // unrelated / malformed stateless message
|
return; // unrelated / malformed stateless message
|
||||||
}
|
}
|
||||||
|
|
||||||
// #370 — explicit "save a version" (human Cmd+S / agent save tool). Edit
|
|
||||||
// rights are already enforced by the readOnly reject above (a reader can't
|
|
||||||
// create a version), exactly as intentional-clear requires.
|
|
||||||
if (message?.type === SAVE_VERSION_MESSAGE_TYPE) {
|
|
||||||
await this.handleSaveVersion(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
if (message?.type !== INTENTIONAL_CLEAR_MESSAGE_TYPE) return;
|
||||||
|
|
||||||
this.intentionalClear.set(
|
this.intentionalClear.set(
|
||||||
@@ -514,117 +488,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — persist an intentional version from the live in-memory ydoc.
|
|
||||||
*
|
|
||||||
* One stateless path serves BOTH the human and the agent; the tier is derived
|
|
||||||
* SERVER-SIDE from the signed connection actor ('agent' → 'agent', anything
|
|
||||||
* else → 'manual'), so the version type cannot be spoofed by the client. We
|
|
||||||
* take the fresh ydoc from the collab process memory and run it through the
|
|
||||||
* EXISTING store path first (so pages.content/ydoc reflect the exact content
|
|
||||||
* being versioned — a REST endpoint would race the up-to-10s-stale page row),
|
|
||||||
* then snapshot it into page_history with the intentional kind.
|
|
||||||
*
|
|
||||||
* Promote-not-dup: if the latest history row already holds this exact content
|
|
||||||
* and it is an autosave (idle/boundary/legacy-null), upgrade its kind in place
|
|
||||||
* instead of duplicating a heavy content row; if it is already 'manual', it is
|
|
||||||
* a no-op (the client shows an "already saved" toast). Otherwise a fresh
|
|
||||||
* version row is written, popping the aggregated contributors from Redis.
|
|
||||||
*/
|
|
||||||
private async handleSaveVersion(data: onStatelessPayload): Promise<void> {
|
|
||||||
const { connection, document, documentName } = data;
|
|
||||||
const context = connection?.context;
|
|
||||||
const pageId = getPageId(documentName);
|
|
||||||
// Unforgeable: 'agent' only for a signed agent connection, else 'manual'.
|
|
||||||
const kind: PageHistoryKind =
|
|
||||||
context?.actor === 'agent' ? 'agent' : 'manual';
|
|
||||||
|
|
||||||
// Flush the live ydoc through the normal store path so the page row + ydoc
|
|
||||||
// hold exactly what we are about to version (also fires the idle enqueue we
|
|
||||||
// supersede below, plus any source-transition boundary). onStoreDocument
|
|
||||||
// only needs document/documentName/context.
|
|
||||||
await this.onStoreDocument({
|
|
||||||
document,
|
|
||||||
documentName,
|
|
||||||
context,
|
|
||||||
} as onStoreDocumentPayload);
|
|
||||||
|
|
||||||
let result:
|
|
||||||
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
await executeTx(this.db, async (trx) => {
|
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
|
||||||
withLock: true,
|
|
||||||
includeContent: true,
|
|
||||||
trx,
|
|
||||||
});
|
|
||||||
if (!page) return;
|
|
||||||
// Never version an effectively-empty page (mirrors the processor's
|
|
||||||
// first-history guard); there is nothing intentional to pin.
|
|
||||||
if (isEmptyParagraphDoc(page.content as any)) return;
|
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
|
||||||
page.id,
|
|
||||||
{ includeContent: true, trx },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
lastHistory &&
|
|
||||||
isDeepStrictEqual(lastHistory.content, page.content)
|
|
||||||
) {
|
|
||||||
// Content is already snapshotted. Promote-not-dup.
|
|
||||||
if (lastHistory.kind === 'manual') {
|
|
||||||
result = {
|
|
||||||
historyId: lastHistory.id,
|
|
||||||
kind: 'manual',
|
|
||||||
alreadySaved: true,
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.pageHistoryRepo.updateHistoryKind(
|
|
||||||
lastHistory.id,
|
|
||||||
kind,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
result = { historyId: lastHistory.id, kind, alreadySaved: false };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fresh version row. Pop the contributors aggregated since the last
|
|
||||||
// snapshot (SPOP); restore them if the write fails so they aren't lost.
|
|
||||||
const contributorIds = await this.collabHistory.popContributors(page.id);
|
|
||||||
try {
|
|
||||||
const saved = await this.pageHistoryRepo.saveHistory(page, {
|
|
||||||
contributorIds,
|
|
||||||
kind,
|
|
||||||
trx,
|
|
||||||
});
|
|
||||||
result = { historyId: saved.id, kind, alreadySaved: false };
|
|
||||||
} catch (err) {
|
|
||||||
await this.collabHistory.addContributors(page.id, contributorIds);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Housekeeping: this explicit version supersedes the page's pending idle
|
|
||||||
// autosnapshot, so cancel it (delayed job → remove() just deletes it) and
|
|
||||||
// end the current idle burst so the next edit starts a fresh max-wait window.
|
|
||||||
await this.historyQueue.remove(pageId).catch(() => undefined);
|
|
||||||
this.idleBurstStart.delete(pageId);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
document.broadcastStateless(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'version.saved',
|
|
||||||
historyId: result.historyId,
|
|
||||||
kind: result.kind,
|
|
||||||
alreadySaved: result.alreadySaved,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onChange(data: onChangePayload) {
|
async onChange(data: onChangePayload) {
|
||||||
const documentName = data.documentName;
|
const documentName = data.documentName;
|
||||||
const userId = data.context?.user?.id;
|
const userId = data.context?.user?.id;
|
||||||
@@ -682,45 +545,17 @@ export class PersistenceExtension implements Extension {
|
|||||||
page: Page,
|
page: Page,
|
||||||
lastUpdatedSource: string,
|
lastUpdatedSource: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// #370 — trailing idle debounce with a max-wait ceiling. One pending idle
|
// Job id + delay arithmetic lives in the pure `computeHistoryJob` (see its
|
||||||
// job per page (jobId = page.id); on every store we remove the pending
|
// doc comment for the agent-delay-0 / age-based-debounce invariants).
|
||||||
// delayed job and re-add it, so the snapshot lands `delay` after edits go
|
|
||||||
// quiet rather than once per store (precedent: workspace.service.ts).
|
|
||||||
// remove() on a delayed job simply deletes it (0 if absent, no throw); if the
|
|
||||||
// job is already ACTIVE and the remove is a no-op, the add still de-dups and
|
|
||||||
// the processor's isDeepStrictEqual gate collapses the duplicate content.
|
|
||||||
//
|
|
||||||
// The FIRST arm of a burst records `burstStart`; computeHistoryJob shrinks
|
|
||||||
// the delay to the remaining max-wait budget from that point, so a continuous
|
|
||||||
// session cannot re-arm the trailing timer forever and starve the snapshot.
|
|
||||||
// A burst marker older than THIS TIER's max-wait means the previous idle job
|
|
||||||
// has already fired — start a fresh window instead of firing immediately on
|
|
||||||
// the next edit. Must use the SAME source-specific max-wait computeHistoryJob
|
|
||||||
// uses (agent 5m / user 10m): a hardcoded USER ceiling would leave an agent
|
|
||||||
// burst's marker stale for 5..10m, forcing delay=0 on every store in that
|
|
||||||
// window and writing one idle row per store — exactly the per-store bloat the
|
|
||||||
// debounce exists to prevent, on the continuous-agent path.
|
|
||||||
const maxWait =
|
|
||||||
lastUpdatedSource === 'agent' ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;
|
|
||||||
const now = Date.now();
|
|
||||||
let burstStart = this.idleBurstStart.get(page.id);
|
|
||||||
if (burstStart === undefined || now - burstStart >= maxWait) {
|
|
||||||
burstStart = now;
|
|
||||||
this.idleBurstStart.set(page.id, burstStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { jobId, delay } = computeHistoryJob(
|
const { jobId, delay } = computeHistoryJob(
|
||||||
page,
|
page,
|
||||||
lastUpdatedSource,
|
lastUpdatedSource,
|
||||||
burstStart,
|
Date.now(),
|
||||||
now,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.historyQueue.remove(jobId).catch(() => undefined);
|
|
||||||
|
|
||||||
await this.historyQueue.add(
|
await this.historyQueue.add(
|
||||||
QueueJob.PAGE_HISTORY,
|
QueueJob.PAGE_HISTORY,
|
||||||
{ pageId: page.id, kind: 'idle' } as IPageHistoryJob,
|
{ pageId: page.id } as IPageHistoryJob,
|
||||||
{ jobId, delay },
|
{ jobId, delay },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,15 +66,6 @@ describe('HistoryProcessor.process', () => {
|
|||||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
|
||||||
// #370 F3 — the processor now serializes its find+save under a page-row lock
|
|
||||||
// via executeTx. A db whose transaction().execute(fn) runs fn with a trx stub
|
|
||||||
// drives the real executeTx() helper without a database.
|
|
||||||
const db = {
|
|
||||||
transaction: () => ({
|
|
||||||
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
// WorkerHost's constructor reads `this.worker`; passing repos positionally
|
||||||
// matches the constructor and avoids the Nest DI container.
|
// matches the constructor and avoids the Nest DI container.
|
||||||
proc = new HistoryProcessor(
|
proc = new HistoryProcessor(
|
||||||
@@ -82,7 +73,6 @@ describe('HistoryProcessor.process', () => {
|
|||||||
pageRepo as any,
|
pageRepo as any,
|
||||||
collabHistory as any,
|
collabHistory as any,
|
||||||
watcherService as any,
|
watcherService as any,
|
||||||
db as any,
|
|
||||||
notificationQueue as any,
|
notificationQueue as any,
|
||||||
generalQueue as any,
|
generalQueue as any,
|
||||||
);
|
);
|
||||||
@@ -136,26 +126,15 @@ describe('HistoryProcessor.process', () => {
|
|||||||
await proc.process(buildJob());
|
await proc.process(buildJob());
|
||||||
|
|
||||||
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
expect(collabHistory.popContributors).toHaveBeenCalledWith(PAGE_ID);
|
||||||
// #370 F3/F9 — the snapshot decision runs under a page-row lock. Pin the lock
|
|
||||||
// structurally so a refactor that drops withLock/trx (silently reintroducing
|
|
||||||
// the TOCTOU double-insert) turns this red. The tx stub is { __trx: true }.
|
|
||||||
expect(pageRepo.findById).toHaveBeenCalledWith(
|
|
||||||
PAGE_ID,
|
|
||||||
expect.objectContaining({ withLock: true, trx: { __trx: true } }),
|
|
||||||
);
|
|
||||||
// #370 F7 — addPageWatchers MUST receive the trx, or its FK-check runs on a
|
|
||||||
// separate connection and self-deadlocks against our FOR UPDATE. Asserting
|
|
||||||
// the trx arg here is exactly what would have caught that regression.
|
|
||||||
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
expect(watcherService.addPageWatchers).toHaveBeenCalledWith(
|
||||||
['u1', 'u2'],
|
['u1', 'u2'],
|
||||||
PAGE_ID,
|
PAGE_ID,
|
||||||
SPACE_ID,
|
SPACE_ID,
|
||||||
WORKSPACE_ID,
|
WORKSPACE_ID,
|
||||||
{ __trx: true },
|
|
||||||
);
|
);
|
||||||
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
expect(pageHistoryRepo.saveHistory).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: PAGE_ID }),
|
expect.objectContaining({ id: PAGE_ID }),
|
||||||
{ contributorIds: ['u1', 'u2'], kind: 'idle', trx: { __trx: true } },
|
{ contributorIds: ['u1', 'u2'] },
|
||||||
);
|
);
|
||||||
expect(generalQueue.add).toHaveBeenCalledWith(
|
expect(generalQueue.add).toHaveBeenCalledWith(
|
||||||
QueueJob.PAGE_BACKLINKS,
|
QueueJob.PAGE_BACKLINKS,
|
||||||
@@ -207,48 +186,6 @@ describe('HistoryProcessor.process', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('COMMIT failure (throw outside the tx callback) → contributors RESTORED', async () => {
|
|
||||||
// #370 F8 — a commit-time failure throws OUTSIDE the callback, so the inner
|
|
||||||
// try/catch does not run; the outer catch must restore the popped set (else a
|
|
||||||
// BullMQ retry writes an unattributed version). Use a db whose execute() runs
|
|
||||||
// the callback THEN throws, simulating a commit abort.
|
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
|
||||||
content: { type: 'doc', content: [] },
|
|
||||||
});
|
|
||||||
const commitFail = {
|
|
||||||
transaction: () => ({
|
|
||||||
execute: async (fn: (trx: any) => Promise<any>) => {
|
|
||||||
await fn({ __trx: true }); // callback succeeds (saveHistory ok)
|
|
||||||
throw new Error('commit aborted'); // ...but the COMMIT fails
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const procCommitFail = new HistoryProcessor(
|
|
||||||
pageHistoryRepo as any,
|
|
||||||
pageRepo as any,
|
|
||||||
collabHistory as any,
|
|
||||||
watcherService as any,
|
|
||||||
commitFail as any,
|
|
||||||
notificationQueue as any,
|
|
||||||
generalQueue as any,
|
|
||||||
);
|
|
||||||
jest
|
|
||||||
.spyOn(procCommitFail['logger'], 'error')
|
|
||||||
.mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
await expect(procCommitFail.process(buildJob())).rejects.toThrow(
|
|
||||||
'commit aborted',
|
|
||||||
);
|
|
||||||
// The inner catch did NOT run (save succeeded), so only the outer catch can
|
|
||||||
// restore — assert it did.
|
|
||||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(PAGE_ID, [
|
|
||||||
'u1',
|
|
||||||
'u2',
|
|
||||||
]);
|
|
||||||
// And the post-snapshot queue work must NOT have run (we rethrew).
|
|
||||||
expect(generalQueue.add).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
it('backlinks + notification queue failures are swallowed (history still committed)', async () => {
|
||||||
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
pageHistoryRepo.findPageLastHistory.mockResolvedValue({
|
||||||
content: { type: 'doc', content: [] },
|
content: { type: 'doc', content: [] },
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ import { isDeepStrictEqual } from 'node:util';
|
|||||||
import { CollabHistoryService } from '../services/collab-history.service';
|
import { CollabHistoryService } from '../services/collab-history.service';
|
||||||
import { WatcherService } from '../../core/watcher/watcher.service';
|
import { WatcherService } from '../../core/watcher/watcher.service';
|
||||||
import { isEmptyParagraphDoc } from '../collaboration.util';
|
import { isEmptyParagraphDoc } from '../collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
|
||||||
import { executeTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Processor(QueueName.HISTORY_QUEUE)
|
@Processor(QueueName.HISTORY_QUEUE)
|
||||||
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
@@ -32,7 +29,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly collabHistory: CollabHistoryService,
|
private readonly collabHistory: CollabHistoryService,
|
||||||
private readonly watcherService: WatcherService,
|
private readonly watcherService: WatcherService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
|
||||||
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
|
||||||
) {
|
) {
|
||||||
@@ -45,9 +41,6 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
try {
|
try {
|
||||||
const { pageId } = job.data;
|
const { pageId } = job.data;
|
||||||
|
|
||||||
// Read the page WITHOUT a lock first, only to bail early on the two cheap
|
|
||||||
// no-write cases (page gone / empty first snapshot) without opening a
|
|
||||||
// transaction. The authoritative check-then-write happens locked below.
|
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
});
|
});
|
||||||
@@ -58,109 +51,40 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #370 F3 — the snapshot decision (findPageLastHistory → saveHistory) must
|
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||||
// be serialized against manual-save/boundary writers, which run under a
|
pageId,
|
||||||
// page-row lock in onStoreDocument. Without it, this processor and a
|
{ includeContent: true },
|
||||||
// concurrent manual-save each read the same lastHistory (MVCC), both see
|
);
|
||||||
// content != lastHistory, and both insert — producing two page_history rows
|
|
||||||
// with IDENTICAL content (one 'idle', one 'manual'), defeating
|
|
||||||
// promote-not-dup and the version-vs-autosave split. Taking the same
|
|
||||||
// page-row lock makes the second writer observe the first's committed row so
|
|
||||||
// the isDeepStrictEqual gate collapses the duplicate. Only the read+write
|
|
||||||
// is transacted; the post-snapshot queue work stays outside.
|
|
||||||
let contributorIds: string[] = [];
|
|
||||||
let snapshotWritten = false;
|
|
||||||
let lastHistoryContent: unknown;
|
|
||||||
// #370 F8 — the contributor set popped from Redis (destructive SPOP) must be
|
|
||||||
// restored if the snapshot does not durably land. The inner try/catch only
|
|
||||||
// covers a throw INSIDE the callback; a COMMIT failure (connection drop,
|
|
||||||
// serialization/deadlock abort on commit — the transient class the epic
|
|
||||||
// already retries) throws OUTSIDE it, rolling the snapshot back while the
|
|
||||||
// pop is already gone. We track the popped set here and restore it in the
|
|
||||||
// outer catch so a BullMQ retry re-attributes the version. addContributors
|
|
||||||
// is an idempotent Redis SADD, so a double-restore is harmless.
|
|
||||||
let poppedForRestore: string[] = [];
|
|
||||||
|
|
||||||
try {
|
if (!lastHistory && isEmptyParagraphDoc(page.content as any)) {
|
||||||
await executeTx(this.db, async (trx) => {
|
this.logger.debug(
|
||||||
const lockedPage = await this.pageRepo.findById(pageId, {
|
`Skipping first history for page ${pageId}: empty content`,
|
||||||
includeContent: true,
|
);
|
||||||
withLock: true,
|
await this.collabHistory.clearContributors(pageId);
|
||||||
trx,
|
|
||||||
});
|
|
||||||
if (!lockedPage) return;
|
|
||||||
|
|
||||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
|
||||||
pageId,
|
|
||||||
{ includeContent: true, trx },
|
|
||||||
);
|
|
||||||
lastHistoryContent = lastHistory?.content;
|
|
||||||
|
|
||||||
if (!lastHistory && isEmptyParagraphDoc(lockedPage.content as any)) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Skipping first history for page ${pageId}: empty content`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
lastHistory &&
|
|
||||||
isDeepStrictEqual(lastHistory.content, lockedPage.content)
|
|
||||||
) {
|
|
||||||
return; // already snapshotted at this content — nothing to write
|
|
||||||
}
|
|
||||||
|
|
||||||
contributorIds = await this.collabHistory.popContributors(pageId);
|
|
||||||
poppedForRestore = contributorIds;
|
|
||||||
try {
|
|
||||||
// Pass `trx` so the watcher insert's FK check (FOR KEY SHARE on
|
|
||||||
// pages[pageId]) runs on the SAME connection that already holds the
|
|
||||||
// FOR UPDATE lock from findById — otherwise it takes the FK lock on a
|
|
||||||
// separate pool connection and self-deadlocks against our own tx.
|
|
||||||
await this.watcherService.addPageWatchers(
|
|
||||||
contributorIds,
|
|
||||||
pageId,
|
|
||||||
lockedPage.spaceId,
|
|
||||||
lockedPage.workspaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
|
|
||||||
// #370 — every job on this queue is a trailing idle-flush autosnapshot.
|
|
||||||
await this.pageHistoryRepo.saveHistory(lockedPage, {
|
|
||||||
contributorIds,
|
|
||||||
kind: job.data.kind ?? 'idle',
|
|
||||||
trx,
|
|
||||||
});
|
|
||||||
snapshotWritten = true;
|
|
||||||
this.logger.debug(`History created for page: ${pageId}`);
|
|
||||||
} catch (err) {
|
|
||||||
await this.collabHistory.addContributors(pageId, contributorIds);
|
|
||||||
poppedForRestore = [];
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// A throw here means the tx did NOT commit (callback threw, or the commit
|
|
||||||
// itself failed and rolled back). If we popped contributors and the inner
|
|
||||||
// catch did not already restore them, restore now so the retry keeps
|
|
||||||
// attribution. snapshotWritten is irrelevant: it is set before commit, so
|
|
||||||
// it can be true even when the commit rolled the snapshot back.
|
|
||||||
if (poppedForRestore.length) {
|
|
||||||
await this.collabHistory.addContributors(pageId, poppedForRestore);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No snapshot written (page vanished / empty-first / unchanged content) →
|
|
||||||
// clear the contributor set for the skip cases and stop.
|
|
||||||
if (!snapshotWritten) {
|
|
||||||
if (!lastHistoryContent && isEmptyParagraphDoc(page.content as any)) {
|
|
||||||
await this.collabHistory.clearContributors(pageId);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
if (
|
||||||
|
!lastHistory ||
|
||||||
|
!isDeepStrictEqual(lastHistory.content, page.content)
|
||||||
|
) {
|
||||||
|
const contributorIds = await this.collabHistory.popContributors(pageId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.watcherService.addPageWatchers(
|
||||||
|
contributorIds,
|
||||||
|
pageId,
|
||||||
|
page.spaceId,
|
||||||
|
page.workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
|
||||||
|
this.logger.debug(`History created for page: ${pageId}`);
|
||||||
|
} catch (err) {
|
||||||
|
await this.collabHistory.addContributors(pageId, contributorIds);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const mentions = extractMentions(page.content);
|
const mentions = extractMentions(page.content);
|
||||||
const pageMentions = extractPageMentions(mentions);
|
const pageMentions = extractPageMentions(mentions);
|
||||||
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
|
const internalLinkSlugIds = extractInternalLinkSlugIds(page.content);
|
||||||
@@ -178,7 +102,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (contributorIds.length > 0 && lastHistoryContent) {
|
if (contributorIds.length > 0 && lastHistory?.content) {
|
||||||
await this.notificationQueue
|
await this.notificationQueue
|
||||||
.add(QueueJob.PAGE_UPDATED, {
|
.add(QueueJob.PAGE_UPDATED, {
|
||||||
pageId,
|
pageId,
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { type Kysely } from 'kysely';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — page-versioning intentionality tier on a history snapshot.
|
|
||||||
*
|
|
||||||
* Adds `page_history.kind`, the three-tier "how intentional was this snapshot"
|
|
||||||
* marker that lets versions (intentional points) be told apart from autosaves:
|
|
||||||
* - 'manual' — a human explicitly saved a version (Cmd+S / Save button)
|
|
||||||
* - 'agent' — the AI agent explicitly saved a version
|
|
||||||
* - 'idle' — trailing idle-flush autosnapshot (safety net)
|
|
||||||
* - 'boundary' — autosnapshot pinned on a source transition (user↔agent↔git)
|
|
||||||
*
|
|
||||||
* Nullable with NO default (mirrors last_updated_source in the agent-provenance
|
|
||||||
* migration): legacy rows predate the marker and read back as `null`, which the
|
|
||||||
* client renders as a plain autosave. Stored as a short varchar to stay
|
|
||||||
* forward-compatible without an enum migration.
|
|
||||||
*/
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.alterTable('page_history')
|
|
||||||
.addColumn('kind', 'varchar(20)', (col) => col)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.alterTable('page_history').dropColumn('kind').execute();
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
|||||||
import { ExpressionBuilder, sql } from 'kysely';
|
import { ExpressionBuilder, sql } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { resolveAgentProvenance } from '../agent-provenance';
|
import { resolveAgentProvenance } from '../agent-provenance';
|
||||||
import { PageHistoryKind } from '../../../collaboration/constants';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
|
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
|
||||||
@@ -47,9 +46,6 @@ export class PageHistoryRepo {
|
|||||||
'lastUpdatedById',
|
'lastUpdatedById',
|
||||||
'lastUpdatedSource',
|
'lastUpdatedSource',
|
||||||
'lastUpdatedAiChatId',
|
'lastUpdatedAiChatId',
|
||||||
// #370 — intentionality tier ('manual' | 'agent' | 'idle' | 'boundary');
|
|
||||||
// null on legacy rows (= autosave). Selected so callers can read/promote it.
|
|
||||||
'kind',
|
|
||||||
'contributorIds',
|
'contributorIds',
|
||||||
'spaceId',
|
'spaceId',
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
@@ -89,15 +85,9 @@ export class PageHistoryRepo {
|
|||||||
|
|
||||||
async saveHistory(
|
async saveHistory(
|
||||||
page: Page,
|
page: Page,
|
||||||
opts?: {
|
opts?: { contributorIds?: string[]; trx?: KyselyTransaction },
|
||||||
contributorIds?: string[];
|
): Promise<void> {
|
||||||
// #370 — intentionality tier for this snapshot. Omitted → null (legacy
|
await this.insertPageHistory(
|
||||||
// autosave semantics). Callers derive it server-side, never from a client.
|
|
||||||
kind?: PageHistoryKind;
|
|
||||||
trx?: KyselyTransaction;
|
|
||||||
},
|
|
||||||
): Promise<PageHistory> {
|
|
||||||
return await this.insertPageHistory(
|
|
||||||
{
|
{
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
slugId: page.slugId,
|
slugId: page.slugId,
|
||||||
@@ -109,7 +99,6 @@ export class PageHistoryRepo {
|
|||||||
// Copy the provenance marker off the page row, as for lastUpdatedById.
|
// Copy the provenance marker off the page row, as for lastUpdatedById.
|
||||||
lastUpdatedSource: page.lastUpdatedSource,
|
lastUpdatedSource: page.lastUpdatedSource,
|
||||||
lastUpdatedAiChatId: page.lastUpdatedAiChatId,
|
lastUpdatedAiChatId: page.lastUpdatedAiChatId,
|
||||||
kind: opts?.kind ?? null,
|
|
||||||
contributorIds: opts?.contributorIds,
|
contributorIds: opts?.contributorIds,
|
||||||
spaceId: page.spaceId,
|
spaceId: page.spaceId,
|
||||||
workspaceId: page.workspaceId,
|
workspaceId: page.workspaceId,
|
||||||
@@ -118,25 +107,6 @@ export class PageHistoryRepo {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #370 — promote an existing snapshot's intentionality tier in place. Used by
|
|
||||||
* the manual-save "promote-not-dup" path: when the latest history row already
|
|
||||||
* holds the exact content being versioned, we upgrade its `kind` instead of
|
|
||||||
* duplicating a heavy content row.
|
|
||||||
*/
|
|
||||||
async updateHistoryKind(
|
|
||||||
pageHistoryId: string,
|
|
||||||
kind: PageHistoryKind,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.updateTable('pageHistory')
|
|
||||||
.set({ kind })
|
|
||||||
.where('id', '=', pageHistoryId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
async findPageHistoryByPageId(pageId: string, pagination: PaginationOptions) {
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.selectFrom('pageHistory')
|
.selectFrom('pageHistory')
|
||||||
|
|||||||
-1
@@ -280,7 +280,6 @@ export interface PageHistory {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
kind: string | null;
|
|
||||||
lastUpdatedAiChatId: string | null;
|
lastUpdatedAiChatId: string | null;
|
||||||
lastUpdatedById: string | null;
|
lastUpdatedById: string | null;
|
||||||
lastUpdatedSource: string | null;
|
lastUpdatedSource: string | null;
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ export interface IStripeSeatsSyncJob {
|
|||||||
|
|
||||||
export interface IPageHistoryJob {
|
export interface IPageHistoryJob {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
// #370 — intentionality tier the worker stamps on the snapshot. All jobs on
|
|
||||||
// this queue are trailing idle-flush autosnapshots, so this is 'idle' (absent
|
|
||||||
// → treated as 'idle' by the processor).
|
|
||||||
kind?: 'idle';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Generated
+3
@@ -272,6 +272,9 @@ 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