Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83f792ca39 | |||
| 1e4925007d |
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -117,6 +117,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Open tabs pick up a new deploy on their own.** After the server is
|
||||
redeployed while a tab is left open for hours, the tab now learns the new
|
||||
build version over the existing WebSocket (announced per-connect, so a natural
|
||||
reconnect delivers it) and shows a "A new version is available" banner with an
|
||||
Update button. To avoid dropping a half-written comment or form, the tab is
|
||||
not reloaded when you merely switch away from it; instead it auto-reloads at
|
||||
the next safe point — the next in-app navigation (or immediately if you click
|
||||
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
|
||||
reload happens per browser session (shared with the existing chunk-load
|
||||
recovery), so a permanent version skew degrades to the banner rather than a
|
||||
reload loop. When the build carries no version info the feature stays inert.
|
||||
|
||||
- **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
|
||||
images as a row that wraps onto the next line on narrow screens. The row is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
"Add": "Добавить",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
@@ -25,16 +28,15 @@ 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;
|
||||
}
|
||||
// (e.g. a genuinely missing chunk) with the shared one-shot session flag
|
||||
// (see @/lib/reload-guard — shared with the proactive version-coherence
|
||||
// path). If it is already set, or the write fails (storage unavailable), we
|
||||
// fall through to the manual recovery UI below rather than risk a loop.
|
||||
if (hasAutoReloaded()) return;
|
||||
if (!markAutoReloaded()) return;
|
||||
// Trace before the reload clears the console (same diagnostic breadcrumb the
|
||||
// proactive version-coherence path writes, tagged with this path).
|
||||
recordReloadBreadcrumb({ path: "chunk-boundary" });
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useNavigate } from "react-router-dom";
|
||||
|
||||
// Mocks for the dirty shell's side-effecting collaborators.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
||||
vi.mock("@/lib/reload-guard", () => ({
|
||||
hasAutoReloaded: vi.fn(() => false),
|
||||
markAutoReloaded: vi.fn(() => true),
|
||||
recordReloadBreadcrumb: vi.fn(),
|
||||
takeReloadBreadcrumb: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
__resetGuardedReloadForTests,
|
||||
} from "./guarded-reload";
|
||||
|
||||
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let visibility: DocumentVisibilityState;
|
||||
|
||||
// Test harness mounted inside a router: it installs the navigation hook and
|
||||
// exposes `navigate` so a test can drive an in-app router navigation.
|
||||
let doNavigate: (to: string) => void;
|
||||
function Harness() {
|
||||
useVersionReloadOnNavigation();
|
||||
const navigate = useNavigate();
|
||||
doNavigate = navigate;
|
||||
return null;
|
||||
}
|
||||
|
||||
function mountHarness() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/start"]}>
|
||||
<Harness />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
act(() => {
|
||||
doNavigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetGuardedReloadForTests();
|
||||
vi.clearAllMocks();
|
||||
mockHasAutoReloaded.mockReturnValue(false);
|
||||
mockMarkAutoReloaded.mockReturnValue(true);
|
||||
|
||||
vi.stubGlobal("APP_VERSION", "test-A");
|
||||
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { reload },
|
||||
});
|
||||
|
||||
visibility = "visible";
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => visibility,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("triggerGuardedReload (variant C)", () => {
|
||||
it("noop when versions match: no banner, no reload", () => {
|
||||
triggerGuardedReload("test-A");
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("noop when the server version is empty (fail-safe)", () => {
|
||||
triggerGuardedReload("");
|
||||
triggerGuardedReload(undefined);
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(show.mock.calls[0][0]).toMatchObject({
|
||||
id: "app-version-reload",
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second navigation must NOT reload again (one-shot was consumed).
|
||||
navigateTo("/again");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
||||
triggerGuardedReload("test-C");
|
||||
navigateTo("/again");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT reload merely from the tab going to the background", () => {
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
visibility = "hidden";
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
||||
visibility = "hidden";
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the banner's Update button reloads immediately", () => {
|
||||
triggerGuardedReload("test-B");
|
||||
const message = show.mock.calls[0][0].message as {
|
||||
props: { onClick: () => void };
|
||||
};
|
||||
message.props.onClick();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
||||
mockHasAutoReloaded.mockReturnValue(true);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
||||
mockMarkAutoReloaded.mockReturnValue(false);
|
||||
mountHarness();
|
||||
triggerGuardedReload("test-B");
|
||||
navigateTo("/next");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
// performAutoReload falls back to showing the banner (initial + fallback).
|
||||
expect(show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
||||
triggerGuardedReload("test-B");
|
||||
triggerGuardedReload("test-B");
|
||||
triggerGuardedReload("test-C");
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import i18n from "@/i18n.ts";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
takeReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
import { decideVersionAction } from "@/features/user/version-coherence";
|
||||
|
||||
// Dirty shell around the pure `decideVersionAction`: it reads globals
|
||||
// (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
|
||||
// Mantine notification, and arms the router-navigation reload hook. Kept
|
||||
// separate from the pure module so the decision stays unit-testable without a
|
||||
// DOM.
|
||||
|
||||
// One fixed id so repeated app-version signals (e.g. every reconnect) update a
|
||||
// single banner instead of stacking a new one each time.
|
||||
const BANNER_ID = "app-version-reload";
|
||||
|
||||
// Module-level idempotency for the current tab-load: once a mismatch has been
|
||||
// handled we don't re-arm the navigation reload or re-show the banner on
|
||||
// subsequent app-version emits.
|
||||
let handled = false;
|
||||
|
||||
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
|
||||
// the background (that would silently drop a half-written comment/form). Instead
|
||||
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
|
||||
// the user is already leaving the current page, so an in-app navigation would
|
||||
// discard that unsaved component-state anyway and the reload adds no extra loss.
|
||||
let pendingNavReload = false;
|
||||
|
||||
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
|
||||
// the (already-visible) banner.
|
||||
let lastServerVersion = "";
|
||||
let lastClientVersion = "";
|
||||
|
||||
// Read the build version baked into THIS bundle. The `typeof` guard avoids a
|
||||
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
|
||||
// where Vite's `define` did not run) — an unknown client version makes the
|
||||
// pure decision no-op (fail-safe).
|
||||
function readClientVersion(): string {
|
||||
return (typeof APP_VERSION !== "undefined" ? APP_VERSION : "").trim();
|
||||
}
|
||||
|
||||
// Perform the actual reload — but only after the shared one-shot flag is
|
||||
// persisted. If the write fails (storage unavailable) we must NOT reload
|
||||
// (mirrors the reactive chunk-load boundary's `catch → return`), and fall back
|
||||
// to the manual banner so the user can still recover.
|
||||
function performAutoReload(): void {
|
||||
if (!markAutoReloaded()) {
|
||||
showReloadBanner();
|
||||
return;
|
||||
}
|
||||
// Trace right before the reload (which clears the console): a persistent
|
||||
// breadcrumb + a log line so the auto-reload is observable in a field report.
|
||||
recordReloadBreadcrumb({
|
||||
path: "proactive",
|
||||
serverVersion: lastServerVersion,
|
||||
clientVersion: lastClientVersion,
|
||||
});
|
||||
console.warn(
|
||||
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function showReloadBanner(): void {
|
||||
notifications.show({
|
||||
id: BANNER_ID,
|
||||
title: i18n.t("A new version is available"),
|
||||
message: (
|
||||
<Button size="xs" mt="xs" onClick={() => performAutoReload()}>
|
||||
{i18n.t("Update")}
|
||||
</Button>
|
||||
),
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a server `app-version` announcement: compare it to this bundle's
|
||||
* version and, on a real mismatch, show the banner and arm a guarded reload for
|
||||
* the next in-app navigation (variant C).
|
||||
*
|
||||
* - real mismatch (first this session) → banner + arm navigation reload. The
|
||||
* banner's "Update" button reloads immediately (same one-shot guard). The tab
|
||||
* is NOT reloaded on visibility change.
|
||||
* - auto-reload already used / storage error → banner only (no arm), so there is
|
||||
* at most one automatic reload per session (loop safety).
|
||||
* - in sync / unknown version → noop (fail-safe).
|
||||
*/
|
||||
export function triggerGuardedReload(
|
||||
rawServerVersion: string | undefined | null,
|
||||
): void {
|
||||
const serverVersion = (rawServerVersion ?? "").trim();
|
||||
const clientVersion = readClientVersion();
|
||||
|
||||
// A storage read error surfaces as autoReloadUsed=true → fail toward NOT
|
||||
// reloading (banner only).
|
||||
const autoReloadUsed = hasAutoReloaded();
|
||||
|
||||
const action = decideVersionAction({
|
||||
serverVersion,
|
||||
clientVersion,
|
||||
autoReloadUsed,
|
||||
});
|
||||
if (action === "noop") return;
|
||||
|
||||
// Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
|
||||
// emits (reconnects) once we've already acted.
|
||||
if (handled) return;
|
||||
handled = true;
|
||||
|
||||
lastServerVersion = serverVersion;
|
||||
lastClientVersion = clientVersion;
|
||||
|
||||
if (action === "banner") {
|
||||
// Entered banner-only (permanent skew, node oscillation, or spent
|
||||
// auto-reload). Log for diagnosability; show the manual banner.
|
||||
console.warn(
|
||||
`[version-coherence] server=${serverVersion} client=${clientVersion}: ` +
|
||||
"auto-reload already spent this session — showing manual banner",
|
||||
);
|
||||
showReloadBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
// action === "reload" (variant C): show the banner and defer the auto-reload
|
||||
// to the next in-app navigation instead of reloading now / on visibility.
|
||||
showReloadBanner();
|
||||
pendingNavReload = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the armed one-shot navigation reload, if any. Called by
|
||||
* `useVersionReloadOnNavigation` on each in-app router navigation.
|
||||
*/
|
||||
export function consumeNavigationReload(): void {
|
||||
if (!pendingNavReload) return;
|
||||
pendingNavReload = false;
|
||||
performAutoReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
|
||||
* NEXT in-app router navigation after a version mismatch. Skips the initial
|
||||
* render so it only reacts to real navigations, not the first location.
|
||||
*/
|
||||
export function useVersionReloadOnNavigation(): void {
|
||||
const location = useLocation();
|
||||
const firstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false;
|
||||
return;
|
||||
}
|
||||
consumeNavigationReload();
|
||||
}, [location.key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
|
||||
* load — the reload cleared the console, so this makes a "tab reloaded itself"
|
||||
* report diagnosable. Call once on app startup.
|
||||
*/
|
||||
export function surfacePreviousReloadBreadcrumb(): void {
|
||||
const crumb = takeReloadBreadcrumb();
|
||||
if (!crumb) return;
|
||||
console.info(
|
||||
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
|
||||
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
|
||||
`at=${new Date(crumb.at).toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Test-only: reset module-level latches between cases.
|
||||
export function __resetGuardedReloadForTests(): void {
|
||||
handled = false;
|
||||
pendingNavReload = false;
|
||||
lastServerVersion = "";
|
||||
lastClientVersion = "";
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
import {
|
||||
triggerGuardedReload,
|
||||
useVersionReloadOnNavigation,
|
||||
surfacePreviousReloadBreadcrumb,
|
||||
} from "@/features/user/guarded-reload.tsx";
|
||||
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
|
||||
|
||||
export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
@@ -22,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
// fetch collab token on load
|
||||
const { data: collab } = useCollabToken();
|
||||
|
||||
// version-coherence: fire the armed one-shot reload on the next in-app
|
||||
// navigation (variant C — a safe point, not on tab backgrounding).
|
||||
useVersionReloadOnNavigation();
|
||||
|
||||
// Surface any breadcrumb left by an auto-reload in the previous page load
|
||||
// (the reload cleared the console) so a field report stays diagnosable.
|
||||
useEffect(() => {
|
||||
surfacePreviousReloadBreadcrumb();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isError) {
|
||||
return;
|
||||
@@ -47,6 +63,15 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
handleConnect();
|
||||
});
|
||||
|
||||
// Register the version-coherence listener SYNCHRONOUSLY, before the socket
|
||||
// connects: the server emits `app-version` immediately in handleConnection,
|
||||
// so a listener attached after connect would miss it on a fast localhost
|
||||
// connect. On a version mismatch the client guard-reloads (banner on a
|
||||
// visible tab, auto-reload on a hidden one) before it hits a stale chunk.
|
||||
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||
triggerGuardedReload(payload?.version);
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("ws disconnected");
|
||||
newSocket.disconnect();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideVersionAction } from "./version-coherence";
|
||||
|
||||
describe("decideVersionAction", () => {
|
||||
it("noop when the server version is empty (fail-safe)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("noop when the client version is empty (fail-safe)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("noop when versions are equal (in sync)", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
|
||||
it("reload on a real mismatch the first time this session", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
autoReloadUsed: false,
|
||||
}),
|
||||
).toBe("reload");
|
||||
});
|
||||
|
||||
it("banner on a mismatch once the session auto-reload is spent", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
autoReloadUsed: true,
|
||||
}),
|
||||
).toBe("banner");
|
||||
});
|
||||
|
||||
it("equal versions stay noop even if auto-reload was already used", () => {
|
||||
expect(
|
||||
decideVersionAction({
|
||||
serverVersion: "v1",
|
||||
clientVersion: "v1",
|
||||
autoReloadUsed: true,
|
||||
}),
|
||||
).toBe("noop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Payload of the per-connect `app-version` socket.io event announced by the
|
||||
// server (ws.gateway.ts) after a successful auth. A dedicated event — NOT a
|
||||
// member of the room-scoped `WebSocketEvent` union (which is discriminated by
|
||||
// `operation`), so it never touches use-query-subscription.
|
||||
export type AppVersionSocketPayload = { version: string };
|
||||
|
||||
/**
|
||||
* Pure decision for the version-coherence guard.
|
||||
*
|
||||
* All inputs are injected (no globals, no side effects) so it is unit-testable
|
||||
* without a DOM or the build-time `APP_VERSION` global (undefined under vitest).
|
||||
*
|
||||
* - `autoReloadUsed` = a session-wide automatic reload has already happened,
|
||||
* so we must not auto-reload again (loop safety, shared with the reactive
|
||||
* chunk-load boundary).
|
||||
*
|
||||
* Returns:
|
||||
* - "noop" — do nothing (unknown version on either side, or already in sync).
|
||||
* - "banner" — show the manual "update available" banner only (no auto-reload).
|
||||
* - "reload" — real first-time mismatch: eligible for a guarded auto-reload.
|
||||
*/
|
||||
export function decideVersionAction(args: {
|
||||
serverVersion: string;
|
||||
clientVersion: string;
|
||||
autoReloadUsed: boolean;
|
||||
}): "reload" | "banner" | "noop" {
|
||||
const { serverVersion, clientVersion, autoReloadUsed } = args;
|
||||
if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
|
||||
if (serverVersion === clientVersion) return "noop"; // in sync
|
||||
if (autoReloadUsed) return "banner"; // one auto-reload per session already spent
|
||||
return "reload"; // real mismatch, first time this session
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
takeReloadBreadcrumb,
|
||||
} from "./reload-guard";
|
||||
|
||||
const FLAG = "chunk-reload-attempted";
|
||||
|
||||
describe("reload-guard", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hasAutoReloaded is false before any reload, true after mark", () => {
|
||||
expect(hasAutoReloaded()).toBe(false);
|
||||
expect(markAutoReloaded()).toBe(true);
|
||||
expect(hasAutoReloaded()).toBe(true);
|
||||
// Uses the same key the reactive chunk-load boundary reads.
|
||||
expect(sessionStorage.getItem(FLAG)).toBe("1");
|
||||
});
|
||||
|
||||
it("hasAutoReloaded returns true when reading storage throws (fail toward not reloading)", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(hasAutoReloaded()).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("markAutoReloaded returns false when writing storage throws", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(markAutoReloaded()).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("records and then takes a breadcrumb once (cleared on read)", () => {
|
||||
recordReloadBreadcrumb({
|
||||
path: "proactive",
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
});
|
||||
const crumb = takeReloadBreadcrumb();
|
||||
expect(crumb).toMatchObject({
|
||||
path: "proactive",
|
||||
serverVersion: "test-B",
|
||||
clientVersion: "test-A",
|
||||
});
|
||||
expect(typeof crumb?.at).toBe("number");
|
||||
// Cleared on read → a second take returns null.
|
||||
expect(takeReloadBreadcrumb()).toBeNull();
|
||||
});
|
||||
|
||||
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
|
||||
expect(takeReloadBreadcrumb()).toBeNull();
|
||||
});
|
||||
|
||||
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
|
||||
vi.stubGlobal("sessionStorage", {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("storage disabled");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
try {
|
||||
expect(() =>
|
||||
recordReloadBreadcrumb({ path: "chunk-boundary" }),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Shared one-shot auto-reload guard.
|
||||
//
|
||||
// Both auto-reload paths — the reactive chunk-load-error-boundary (recovers
|
||||
// AFTER a stale lazy chunk 404s) and the proactive version-coherence feature
|
||||
// (reloads BEFORE the tab hits a stale chunk) — go through these functions so
|
||||
// they share ONE session-scoped flag. Net guarantee: at most a single
|
||||
// automatic reload per browser session across both paths. Once the flag is set
|
||||
// (or sessionStorage is unavailable), every further mismatch degrades to a
|
||||
// manual banner/UI — no reload loop under permanent skew, node oscillation, or
|
||||
// a disabled storage.
|
||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||
|
||||
/**
|
||||
* Has an automatic reload already been performed (or attempted) this session?
|
||||
*
|
||||
* A storage read error (private mode / disabled) is reported as `true` so the
|
||||
* caller fails toward NOT reloading — an unguarded loop is worse than a stale
|
||||
* tab the user can reload manually.
|
||||
*/
|
||||
export function hasAutoReloaded(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(RELOAD_FLAG) !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that an automatic reload is being performed this session.
|
||||
*
|
||||
* Returns whether the write succeeded. A `false` return (storage unavailable)
|
||||
* means the caller MUST NOT reload — otherwise the flag would never stick and
|
||||
* the reload could loop.
|
||||
*/
|
||||
export function markAutoReloaded(): boolean {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic breadcrumb for an automatic reload. Written right before
|
||||
// window.location.reload() (which clears the console) and read back on the next
|
||||
// page load, so a "the tab reloaded itself / it's looping" field report is
|
||||
// diagnosable: which path fired (proactive version-coherence vs the reactive
|
||||
// chunk-load boundary) and which version pair triggered it. sessionStorage
|
||||
// survives a same-tab reload, unlike the console.
|
||||
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
|
||||
|
||||
export type ReloadBreadcrumb = {
|
||||
path: "proactive" | "chunk-boundary";
|
||||
serverVersion?: string;
|
||||
clientVersion?: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist a best-effort breadcrumb just before an automatic reload. Failures
|
||||
* (storage unavailable) are swallowed — this is diagnostics only and must never
|
||||
* block or alter the reload decision.
|
||||
*/
|
||||
export function recordReloadBreadcrumb(
|
||||
entry: Omit<ReloadBreadcrumb, "at">,
|
||||
): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
RELOAD_BREADCRUMB_KEY,
|
||||
JSON.stringify({ ...entry, at: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// best-effort diagnostics only
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and clear the breadcrumb left by an auto-reload in the previous page
|
||||
* load. Cleared on read so it surfaces exactly once per reload.
|
||||
*/
|
||||
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
|
||||
return JSON.parse(raw) as ReloadBreadcrumb;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import * as path from "path";
|
||||
import * as fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const envPath = path.resolve(process.cwd(), "..", "..");
|
||||
@@ -24,7 +25,32 @@ function resolveAppVersion(cwd: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
||||
// the exact same build id the bundle was compiled with. The value is the SAME
|
||||
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
||||
// global are identical by construction — the single source of truth (no
|
||||
// runtime-env second copy that could drift and cause a false version mismatch).
|
||||
function versionJsonPlugin(version: string): Plugin {
|
||||
let outDir = "dist";
|
||||
return {
|
||||
name: "emit-version-json",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
outDir = config.build.outDir;
|
||||
},
|
||||
writeBundle() {
|
||||
const root = path.resolve(process.cwd(), outDir);
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "version.json"),
|
||||
JSON.stringify({ version }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const appVersion = resolveAppVersion(envPath);
|
||||
const {
|
||||
APP_URL,
|
||||
FILE_UPLOAD_SIZE_LIMIT,
|
||||
@@ -52,10 +78,11 @@ export default defineConfig(({ mode }) => {
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
APP_VERSION: JSON.stringify(appVersion),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
versionJsonPlugin(appVersion),
|
||||
// Emit .br and .gz next to every built asset so the server can serve the
|
||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||
compression({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { readClientBuildVersion } from './client-version';
|
||||
|
||||
describe('readClientBuildVersion', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const writeVersionJson = (content: string) =>
|
||||
fs.writeFileSync(join(dir, 'version.json'), content);
|
||||
|
||||
it('returns the version from a valid version.json', () => {
|
||||
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('test-A');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace in the version', () => {
|
||||
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
||||
});
|
||||
|
||||
it('returns "" when version.json is missing', () => {
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" on malformed JSON', () => {
|
||||
writeVersionJson('{ not json');
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the version field is absent', () => {
|
||||
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the version field is not a string', () => {
|
||||
writeVersionJson(JSON.stringify({ version: 123 }));
|
||||
expect(readClientBuildVersion(dir)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the path does not exist at all', () => {
|
||||
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the built client bundle directory
|
||||
* (`apps/client/dist`) shipped into the runtime image.
|
||||
*
|
||||
* The `../` depth is anchored on THIS module's compiled location
|
||||
* (`dist/common/helpers`). `integrations/static` sits at the same depth under
|
||||
* the compiled root, so both callers (StaticModule and readClientBuildVersion)
|
||||
* MUST share this single helper rather than duplicating the depth — a copy in a
|
||||
* module at a different depth would silently resolve to the wrong directory.
|
||||
*/
|
||||
export function resolveClientDistPath(): string {
|
||||
return join(__dirname, '..', '..', '..', '..', 'client/dist');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the build version the client bundle was compiled with, from
|
||||
* `<clientDistPath>/version.json` (written by the Vite build — the single
|
||||
* source of truth shared by the baked-in `APP_VERSION` global and this file).
|
||||
*
|
||||
* Fail-safe: any error (missing file, unreadable, bad JSON, non-string
|
||||
* version) yields `''`. The caller treats an empty version as "unknown" and
|
||||
* the whole version-coherence feature stays silently inert — existing deploys
|
||||
* without the file keep working unchanged.
|
||||
*/
|
||||
export function readClientBuildVersion(clientDistPath: string): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(join(clientDistPath, 'version.json'), 'utf8');
|
||||
const version = (JSON.parse(raw) as { version?: unknown }).version;
|
||||
return typeof version === 'string' ? version.trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
export * from './security-headers';
|
||||
export * from './client-version';
|
||||
|
||||
@@ -34,8 +34,6 @@ import {
|
||||
isMetricsEnabled,
|
||||
observeMcpTool,
|
||||
incConnectTimeout,
|
||||
incGetPageCacheHit,
|
||||
incGetPageCacheMiss,
|
||||
} from '../metrics/metrics.registry';
|
||||
|
||||
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
|
||||
@@ -359,10 +357,6 @@ export class McpService implements OnModuleDestroy {
|
||||
observeMcpTool(labels?.tool ?? 'other', value);
|
||||
} else if (name === 'collab_connect_timeouts_total') {
|
||||
incConnectTimeout();
|
||||
} else if (name === 'mcp_getpage_cache_hits_total') {
|
||||
incGetPageCacheHit();
|
||||
} else if (name === 'mcp_getpage_cache_misses_total') {
|
||||
incGetPageCacheMiss();
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -25,15 +25,6 @@ export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
|
||||
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
|
||||
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
|
||||
|
||||
// #479 — getPage PM→Markdown conversion cache hit/miss counters. Emitted by the
|
||||
// MCP package via its dependency-neutral onMetric sink and routed onto these two
|
||||
// prom counters by the mcp.service onMetric callback; a >50% hit-rate is the
|
||||
// success signal for the getPage perf work. Same "do not rename" contract.
|
||||
export const METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL =
|
||||
'mcp_getpage_cache_hits_total';
|
||||
export const METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL =
|
||||
'mcp_getpage_cache_misses_total';
|
||||
|
||||
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
||||
// for typical web/DB latencies without exploding series cardinality.
|
||||
export const HTTP_BUCKETS = [
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
METRIC_DB_QUERY_DURATION,
|
||||
METRIC_HTTP_REQUEST_DURATION,
|
||||
METRIC_MCP_TOOL_DURATION,
|
||||
METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
|
||||
METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
|
||||
sizeBucket,
|
||||
} from './metrics.constants';
|
||||
|
||||
@@ -63,9 +61,6 @@ let connectTimeoutsCounter: Counter | null = null;
|
||||
let collabConnectHist: Histogram | null = null;
|
||||
let collabAuthHist: Histogram | null = null;
|
||||
let mcpToolHist: Histogram<'tool'> | null = null;
|
||||
// #479 — getPage conversion-cache hit/miss counters.
|
||||
let getPageCacheHitsCounter: Counter | null = null;
|
||||
let getPageCacheMissesCounter: Counter | null = null;
|
||||
|
||||
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
|
||||
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
|
||||
@@ -180,18 +175,6 @@ function init(): void {
|
||||
buckets: MCP_TOOL_BUCKETS,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
getPageCacheHitsCounter = new Counter({
|
||||
name: METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
|
||||
help: 'Total getPage PM→Markdown conversions served from the cache (skipped)',
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
getPageCacheMissesCounter = new Counter({
|
||||
name: METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
|
||||
help: 'Total getPage PM→Markdown conversions computed (cache misses)',
|
||||
registers: [registry],
|
||||
});
|
||||
}
|
||||
|
||||
// Runs once when this module is first imported. Safe to call again (idempotent).
|
||||
@@ -264,14 +247,6 @@ export function observeCollabAuth(seconds: number): void {
|
||||
collabAuthHist?.observe(seconds);
|
||||
}
|
||||
|
||||
export function incGetPageCacheHit(): void {
|
||||
getPageCacheHitsCounter?.inc();
|
||||
}
|
||||
|
||||
export function incGetPageCacheMiss(): void {
|
||||
getPageCacheMissesCounter?.inc();
|
||||
}
|
||||
|
||||
export function observeMcpTool(tool: string, seconds: number): void {
|
||||
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
|
||||
// guarantees it comes from the registered-tool set) — never free-form input —
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
incConnectTimeout,
|
||||
incDocLoad,
|
||||
incDocUnload,
|
||||
incGetPageCacheHit,
|
||||
incGetPageCacheMiss,
|
||||
isMetricsEnabled,
|
||||
observeCollabAuth,
|
||||
observeCollabConnect,
|
||||
@@ -199,8 +197,6 @@ describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
|
||||
incDocLoad();
|
||||
incDocUnload();
|
||||
incConnectTimeout();
|
||||
incGetPageCacheHit();
|
||||
incGetPageCacheMiss();
|
||||
// Registering a source must not create the gauge or invoke the fn.
|
||||
registerDocsOpenSource(() => {
|
||||
throw new Error('docsOpenSource must NOT be called when disabled');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'path';
|
||||
import * as fs from 'node:fs';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { resolveClientDistPath } from '../../common/helpers/client-version';
|
||||
|
||||
/**
|
||||
* Resolve the response headers for a statically served client asset.
|
||||
@@ -56,14 +57,7 @@ export class StaticModule implements OnModuleInit {
|
||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||
const app = httpAdapter.getInstance();
|
||||
|
||||
const clientDistPath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client/dist',
|
||||
);
|
||||
const clientDistPath = resolveClientDistPath();
|
||||
|
||||
const indexFilePath = join(clientDistPath, 'index.html');
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { TokenService } from '../core/auth/services/token.service';
|
||||
import { JwtPayload, JwtType } from '../core/auth/dto/jwt-payload';
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||
import { WsService } from './ws.service';
|
||||
import { getSpaceRoomName, getUserRoomName } from './ws.utils';
|
||||
import {
|
||||
readClientBuildVersion,
|
||||
resolveClientDistPath,
|
||||
} from '../common/helpers/client-version';
|
||||
import * as cookie from 'cookie';
|
||||
|
||||
@WebSocketGateway({
|
||||
@@ -20,17 +24,40 @@ import * as cookie from 'cookie';
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class WsGateway
|
||||
implements OnGatewayConnection, OnGatewayInit, OnModuleDestroy
|
||||
implements
|
||||
OnGatewayConnection,
|
||||
OnGatewayInit,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(WsGateway.name);
|
||||
|
||||
// The build version of the client bundle shipped in this image, read once at
|
||||
// startup from client/dist/version.json (single source of truth, same value
|
||||
// baked into the client's APP_VERSION). Empty string => version.json missing
|
||||
// or empty => the proactive version-coherence reload feature stays inert.
|
||||
private appVersion = '';
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private spaceMemberRepo: SpaceMemberRepo,
|
||||
private wsService: WsService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.appVersion = readClientBuildVersion(resolveClientDistPath());
|
||||
if (this.appVersion) {
|
||||
this.logger.log(`app-version reload: ACTIVE (v=${this.appVersion})`);
|
||||
} else {
|
||||
this.logger.log(
|
||||
'app-version reload: DISABLED (version.json missing/empty)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
afterInit(server: Server): void {
|
||||
this.wsService.setServer(server);
|
||||
}
|
||||
@@ -55,6 +82,14 @@ export class WsGateway
|
||||
const spaceRooms = userSpaceIds.map((id) => getSpaceRoomName(id));
|
||||
|
||||
client.join([userRoom, workspaceRoom, ...spaceRooms]);
|
||||
|
||||
// Announce this container's client build version to the freshly
|
||||
// authenticated socket. On a redeploy the client reconnects to the new
|
||||
// container and receives the new version here, letting it guard-reload
|
||||
// before it hits a stale lazy chunk. Per-connect only (no broadcast):
|
||||
// natural reconnect covers both single-container and cluster without a
|
||||
// thundering-herd fleet reload.
|
||||
client.emit('app-version', { version: this.appVersion });
|
||||
} catch (err) {
|
||||
client.emit('Unauthorized');
|
||||
client.disconnect();
|
||||
|
||||
@@ -23,7 +23,6 @@ import { acquireCollabSession } from "../lib/collab-session.js";
|
||||
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
|
||||
import { formatDocmostAxiosError } from "./errors.js";
|
||||
import { GetPageConversionCache } from "./getpage-cache.js";
|
||||
|
||||
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
|
||||
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
|
||||
@@ -160,13 +159,6 @@ export abstract class DocmostClientContext {
|
||||
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
|
||||
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
|
||||
|
||||
// Content-addressed conversion cache for getPage (issue #479). Keyed on
|
||||
// (canonical pageId, updatedAt, optionsHash) -> the converted Markdown, so a
|
||||
// re-read of an UNCHANGED page skips the expensive convertProseMirrorToMarkdown
|
||||
// tree walk. Per-instance (a DocmostClient is built per user / per chat), so a
|
||||
// cached conversion can never leak across identities. See getpage-cache.ts.
|
||||
protected getPageCache = new GetPageConversionCache();
|
||||
|
||||
// Two construction forms:
|
||||
// - new DocmostClient(config) // discriminated union (current)
|
||||
// - new DocmostClient(baseURL, email, password) // legacy positional creds
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
// Content-addressed LRU cache for the PM->Markdown conversion in getPage
|
||||
// (issue #479). getPage is the dominant agent op (812 calls / 2h, p95 840ms);
|
||||
// the bulk of its cost is convertProseMirrorToMarkdown — a full ProseMirror-tree
|
||||
// walk over the page content (hundreds of KB of JSON on large pages) run on
|
||||
// EVERY read. Since agents re-read far more than they write (812 reads vs 28
|
||||
// writes in the sample), most conversions re-produce the SAME markdown from
|
||||
// UNCHANGED content. This cache skips the recomputation on a hit.
|
||||
//
|
||||
// KEY = (pageId, updatedAt, optionsHash):
|
||||
// - pageId: the page's CANONICAL UUID (resultData.id), not the agent-supplied
|
||||
// slugId — so a slugId read and a UUID read of the same page share one entry.
|
||||
// - updatedAt: comes from the SAME /pages/info response as `content`, so the
|
||||
// two are mutually consistent; a changed page yields a new updatedAt -> a new
|
||||
// key -> automatic, precise invalidation (no stale markdown is ever served).
|
||||
// - optionsHash: a stable hash of the conversion options. getPage passes
|
||||
// `{dropResolvedCommentAnchors:true}` while exportPageMarkdown passes the
|
||||
// defaults (#328) — DIFFERENT output for the same content, so the options
|
||||
// MUST be part of the key or a hit would serve the wrong variant.
|
||||
//
|
||||
// BOUNDS: evict the LEAST-recently-used entry when EITHER the entry count OR the
|
||||
// total stored bytes would exceed its cap. Large pages are hundreds of KB, so a
|
||||
// byte cap (not just a count cap) is what actually bounds memory. A Map iterates
|
||||
// in insertion order, so the first key is the LRU entry; a hit re-inserts its key
|
||||
// to move it to the most-recently-used end.
|
||||
//
|
||||
// This module is dependency-neutral (no axios/client/prom-client): a plain class
|
||||
// the shared client context owns one instance of, so the cache persists across
|
||||
// getPage calls on a single DocmostClient instance (built per user / per chat).
|
||||
|
||||
/** A stable, order-insensitive hash of the conversion options object. */
|
||||
export function hashConvertOptions(options: unknown): string {
|
||||
// JSON.stringify with SORTED keys makes the hash independent of key order, so
|
||||
// {a:1,b:2} and {b:2,a:1} collapse to one entry. undefined/null options -> a
|
||||
// fixed empty-object key, matching a caller that passes no options at all.
|
||||
if (options === undefined || options === null) return "{}";
|
||||
return stableStringify(options);
|
||||
}
|
||||
|
||||
function stableStringify(value: any): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||
const keys = Object.keys(value).sort();
|
||||
return `{${keys
|
||||
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
markdown: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface GetPageCacheOptions {
|
||||
/** Max number of entries before LRU eviction. Default 50. */
|
||||
maxEntries?: number;
|
||||
/** Max total stored bytes before LRU eviction. Default 10 MB. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export class GetPageConversionCache {
|
||||
private readonly maxEntries: number;
|
||||
private readonly maxBytes: number;
|
||||
// Insertion-ordered: the FIRST key is the least-recently-used entry.
|
||||
private readonly map = new Map<string, CacheEntry>();
|
||||
private totalBytes = 0;
|
||||
|
||||
constructor(opts: GetPageCacheOptions = {}) {
|
||||
// A non-positive/NaN cap is treated as "use the default", never as an
|
||||
// unbounded (or always-empty) cache — a silently unbounded cache would leak
|
||||
// memory, and an always-empty one would defeat the whole optimization.
|
||||
this.maxEntries =
|
||||
Number.isFinite(opts.maxEntries) && (opts.maxEntries as number) > 0
|
||||
? Math.floor(opts.maxEntries as number)
|
||||
: 50;
|
||||
this.maxBytes =
|
||||
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
|
||||
? Math.floor(opts.maxBytes as number)
|
||||
: 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/** Compose the content-addressed key from its three parts. */
|
||||
static key(pageId: string, updatedAt: string, optionsHash: string): string {
|
||||
// A space separates the parts so no combination of values can collide by
|
||||
// concatenation: a canonical UUID and an ISO updatedAt never contain a
|
||||
// space, so the boundaries between the three parts are unambiguous.
|
||||
return `${pageId} ${updatedAt} ${optionsHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the cached markdown for `key`, or undefined on a miss. A hit moves
|
||||
* the entry to the most-recently-used end (delete + re-set) so the LRU order
|
||||
* reflects real access, not just insertion.
|
||||
*/
|
||||
get(key: string): string | undefined {
|
||||
const entry = this.map.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
return entry.markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store `markdown` under `key`, then evict LRU entries until BOTH caps hold.
|
||||
* Re-storing an existing key refreshes its value and recency (its old bytes
|
||||
* are subtracted first, so totalBytes stays exact).
|
||||
*/
|
||||
set(key: string, markdown: string): void {
|
||||
// Byte size of the stored string (UTF-8). A single entry larger than the
|
||||
// whole byte cap is still stored (so getPage always gets a hit next time),
|
||||
// then the eviction loop below simply cannot shrink below it — accepted:
|
||||
// one oversized page is bounded by the page itself, not a cache leak.
|
||||
const bytes = Buffer.byteLength(markdown, "utf8");
|
||||
const existing = this.map.get(key);
|
||||
if (existing !== undefined) {
|
||||
this.totalBytes -= existing.bytes;
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, { markdown, bytes });
|
||||
this.totalBytes += bytes;
|
||||
this.evict();
|
||||
}
|
||||
|
||||
/** Evict the LRU entry until both the count and byte caps are satisfied. */
|
||||
private evict(): void {
|
||||
while (
|
||||
this.map.size > this.maxEntries ||
|
||||
(this.totalBytes > this.maxBytes && this.map.size > 1)
|
||||
) {
|
||||
// The first key in insertion order is the least-recently-used.
|
||||
const oldest = this.map.keys().next().value as string | undefined;
|
||||
if (oldest === undefined) break;
|
||||
const entry = this.map.get(oldest);
|
||||
this.map.delete(oldest);
|
||||
if (entry) this.totalBytes -= entry.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current entry count (test/introspection). */
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
/** Current total stored bytes (test/introspection). */
|
||||
get bytes(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,7 @@ import {
|
||||
filterComment,
|
||||
filterSearchResult,
|
||||
} from "../lib/filters.js";
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
type ConvertProseMirrorToMarkdownOptions,
|
||||
} from "../lib/markdown-converter.js";
|
||||
import {
|
||||
GetPageConversionCache,
|
||||
hashConvertOptions,
|
||||
} from "./getpage-cache.js";
|
||||
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
||||
import {
|
||||
collectInternalFileNodes,
|
||||
normalizeFileUrl,
|
||||
@@ -402,19 +395,6 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
|
||||
|
||||
/** Raw page info including the ProseMirror JSON content and slugId. */
|
||||
|
||||
/**
|
||||
* Overridable seam over convertProseMirrorToMarkdown (issue #479). Production
|
||||
* just delegates; it exists as a method so a unit test can spy on it and
|
||||
* assert the conversion is genuinely SKIPPED on a getPage cache HIT (the whole
|
||||
* point of the cache) — an ESM named import cannot be intercepted otherwise.
|
||||
*/
|
||||
protected convertPageMarkdown(
|
||||
content: any,
|
||||
options: ConvertProseMirrorToMarkdownOptions,
|
||||
): string {
|
||||
return convertProseMirrorToMarkdown(content, options);
|
||||
}
|
||||
|
||||
async getPage(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
const resultData = await this.getPageRaw(pageId);
|
||||
@@ -423,55 +403,13 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
|
||||
// discussions. Active anchors are kept. (The lossless exportPageMarkdown
|
||||
// round-trip deliberately does NOT pass this flag — resolved anchors there
|
||||
// must be preserved.)
|
||||
//
|
||||
// Content-addressed conversion cache (issue #479): the PM->Markdown walk is
|
||||
// the dominant cost of this hot read op. Key on the page's canonical UUID +
|
||||
// updatedAt (both from THIS /pages/info response, so mutually consistent) +
|
||||
// a hash of the conversion options. A hit returns the cached markdown and
|
||||
// skips the walk; a miss converts and stores. The cached value is the
|
||||
// conversion output BEFORE the {{SUBPAGES}} substitution below, which uses
|
||||
// live subpage data and stays outside the cache — so the final result is
|
||||
// byte-identical to the uncached path.
|
||||
const convertOptions = { dropResolvedCommentAnchors: true };
|
||||
let content = "";
|
||||
if (resultData.content) {
|
||||
// Only cache when we have a stable identity+version for the key. Both come
|
||||
// from the same response; if either is missing (unexpected server shape),
|
||||
// fall back to converting uncached rather than keying on a partial tuple.
|
||||
const cacheable =
|
||||
typeof resultData.id === "string" &&
|
||||
typeof resultData.updatedAt === "string";
|
||||
const cacheKey = cacheable
|
||||
? GetPageConversionCache.key(
|
||||
resultData.id,
|
||||
resultData.updatedAt,
|
||||
hashConvertOptions(convertOptions),
|
||||
)
|
||||
: null;
|
||||
let content = resultData.content
|
||||
? convertProseMirrorToMarkdown(resultData.content, {
|
||||
dropResolvedCommentAnchors: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
const cached = cacheKey ? this.getPageCache.get(cacheKey) : undefined;
|
||||
if (cached !== undefined) {
|
||||
content = cached;
|
||||
this.onMetricFn?.("mcp_getpage_cache_hits_total", 1);
|
||||
} else {
|
||||
// Goes through the convertPageMarkdown seam (not the raw import) so a
|
||||
// test can assert the conversion is SKIPPED on a hit (issue #479 F2).
|
||||
content = this.convertPageMarkdown(resultData.content, convertOptions);
|
||||
if (cacheKey) this.getPageCache.set(cacheKey, content);
|
||||
// A non-cacheable page (missing id/updatedAt) is still a genuine
|
||||
// conversion, so it counts as a miss for an honest hit-rate.
|
||||
this.onMetricFn?.("mcp_getpage_cache_misses_total", 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Always fetch subpages to provide context to the agent.
|
||||
//
|
||||
// NOT parallelizable with the page fetch (issue #479 asked to check): the
|
||||
// sidebar-pages endpoint REQUIRES spaceId in its POST body, and spaceId is
|
||||
// only known FROM this page fetch's response (resolvePageId yields the UUID
|
||||
// but never the spaceId). So `Promise.all([pageFetch, subpagesFetch])` would
|
||||
// have to invent a spaceId it does not have — the two calls are inherently
|
||||
// sequential. Correctness wins; the conversion cache above is the real speedup.
|
||||
// Always fetch subpages to provide context to the agent
|
||||
let subpages: any[] = [];
|
||||
try {
|
||||
// `pageId` may be a slugId, but the sidebar-pages endpoint requires the
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
// Mock-HTTP integration tests for the getPage conversion cache (issue #479).
|
||||
// A local http.createServer stands in for Docmost (same harness style as
|
||||
// get-page-context.test.mjs) so everything is deterministic and offline.
|
||||
//
|
||||
// Verifies end-to-end through the real client that:
|
||||
// - the FIRST getPage of a page is a MISS (mcp_getpage_cache_misses_total)
|
||||
// and converts the content (the server's convert-representative counter);
|
||||
// - a SECOND getPage of the same (pageId, updatedAt) is a HIT
|
||||
// (mcp_getpage_cache_hits_total) and returns BYTE-IDENTICAL output while
|
||||
// skipping the conversion;
|
||||
// - a changed updatedAt is a fresh key -> MISS again;
|
||||
// - the returned shape still resolves page + subpages.
|
||||
import { test, after, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => (raw += chunk));
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const openServers = [];
|
||||
after(async () => {
|
||||
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
|
||||
});
|
||||
|
||||
const PAGE_UUID = "00000000-0000-4000-8000-000000000010";
|
||||
const SPACE_UUID = "00000000-0000-4000-8000-0000000000aa";
|
||||
const CHILD_UUID = "00000000-0000-4000-8000-0000000000bb";
|
||||
|
||||
// A small ProseMirror doc so the converter produces non-trivial markdown.
|
||||
function makeDoc(text) {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// state.info counts /pages/info hits; state.updatedAt / state.text drive the
|
||||
// content+version returned; state.sidebar counts sidebar-pages hits;
|
||||
// state.subpages (when set) drives the child list the sidebar endpoint returns,
|
||||
// so a test can vary the live subpages across two reads of the same page.
|
||||
function spawn(state) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
return sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
state.info++;
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
id: PAGE_UUID,
|
||||
slugId: "slug123456",
|
||||
title: "Cached Page",
|
||||
parentPageId: null,
|
||||
spaceId: SPACE_UUID,
|
||||
updatedAt: state.updatedAt,
|
||||
content: makeDoc(state.text),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
state.sidebar++;
|
||||
const items = state.subpages ?? [
|
||||
{ id: CHILD_UUID, title: "Child", hasChildren: false },
|
||||
];
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
meta: { hasNextPage: false, nextCursor: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
return sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
openServers.push(server);
|
||||
resolve(`http://127.0.0.1:${server.address().port}/api`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function makeClient(baseURL, metrics) {
|
||||
return new DocmostClient({
|
||||
apiUrl: baseURL,
|
||||
getToken: async () => "access",
|
||||
onMetric: (name, value) => {
|
||||
metrics[name] = (metrics[name] ?? 0) + value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("first read MISS, second read HIT with byte-identical output; convert runs once", async () => {
|
||||
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Hello world" };
|
||||
const baseURL = await spawn(state);
|
||||
const metrics = {};
|
||||
const client = makeClient(baseURL, metrics);
|
||||
|
||||
const first = await client.getPage(PAGE_UUID);
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read is a miss");
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"] ?? 0, 0, "no hit yet");
|
||||
|
||||
const second = await client.getPage(PAGE_UUID);
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a hit");
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "still one miss");
|
||||
|
||||
// BYTE-IDENTICAL: the cache only skips recomputation, never changes output.
|
||||
assert.deepEqual(second, first, "cached result is identical to the uncached one");
|
||||
assert.equal(
|
||||
JSON.stringify(second),
|
||||
JSON.stringify(first),
|
||||
"serialized output is byte-identical",
|
||||
);
|
||||
|
||||
// The page fetch + subpages fetch still happen every call (only the CPU
|
||||
// conversion is cached); both reads hit /pages/info and sidebar-pages.
|
||||
assert.equal(state.info, 2, "both reads still fetch /pages/info");
|
||||
assert.equal(state.sidebar, 2, "both reads still fetch subpages");
|
||||
|
||||
// Shape sanity: content present, subpages resolved.
|
||||
assert.equal(typeof second.data.content, "string");
|
||||
assert.ok(second.data.content.includes("Hello world"));
|
||||
assert.deepEqual(second.data.subpages, [{ id: CHILD_UUID, title: "Child" }]);
|
||||
});
|
||||
|
||||
test("a changed updatedAt is a fresh key -> MISS again, with the NEW content", async () => {
|
||||
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Version one" };
|
||||
const baseURL = await spawn(state);
|
||||
const metrics = {};
|
||||
const client = makeClient(baseURL, metrics);
|
||||
|
||||
const a = await client.getPage(PAGE_UUID); // miss
|
||||
const b = await client.getPage(PAGE_UUID); // hit
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1);
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1);
|
||||
assert.ok(a.data.content.includes("Version one"));
|
||||
|
||||
// The page changes: new updatedAt AND new content.
|
||||
state.updatedAt = "2026-02-02T00:00:00Z";
|
||||
state.text = "Version two";
|
||||
|
||||
const c = await client.getPage(PAGE_UUID); // miss on the new key
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 2, "changed version -> miss");
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "no stale hit");
|
||||
assert.ok(c.data.content.includes("Version two"), "the NEW content is served");
|
||||
assert.ok(!c.data.content.includes("Version one"), "no stale markdown");
|
||||
|
||||
const d = await client.getPage(PAGE_UUID); // hit on the new key
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 2, "the new snapshot caches too");
|
||||
});
|
||||
|
||||
test("a slugId read and a UUID read of the same page share one cache entry", async () => {
|
||||
// resolvePageId maps the slugId -> UUID via /pages/info; the cache keys on the
|
||||
// canonical UUID (resultData.id), so both inputs land on the same entry.
|
||||
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Shared" };
|
||||
const baseURL = await spawn(state);
|
||||
const metrics = {};
|
||||
const client = makeClient(baseURL, metrics);
|
||||
|
||||
await client.getPage(PAGE_UUID); // miss (keyed on UUID)
|
||||
await client.getPage("slug123456"); // the server returns the same id -> HIT
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "one conversion total");
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "slugId read hits the UUID entry");
|
||||
});
|
||||
|
||||
test("on a conversion HIT, the {{SUBPAGES}} block reflects the LIVE subpages, not the cached ones", async () => {
|
||||
// The whole byte-identity guarantee: the cache stores the conversion output
|
||||
// BEFORE the {{SUBPAGES}} substitution, so a re-read of an UNCHANGED page still
|
||||
// splices the FRESH subpage list. The page body itself contains {{SUBPAGES}}
|
||||
// (converts to a literal placeholder); getPage replaces it with the live list.
|
||||
const CHILD_A = "00000000-0000-4000-8000-0000000000a1";
|
||||
const CHILD_B = "00000000-0000-4000-8000-0000000000b2";
|
||||
const state = {
|
||||
info: 0,
|
||||
sidebar: 0,
|
||||
updatedAt: "2026-01-01T00:00:00Z", // FIXED across both reads -> conversion cache HIT
|
||||
text: "Body before {{SUBPAGES}} body after",
|
||||
subpages: [{ id: CHILD_A, title: "Alpha", hasChildren: false }],
|
||||
};
|
||||
const baseURL = await spawn(state);
|
||||
const metrics = {};
|
||||
const client = makeClient(baseURL, metrics);
|
||||
|
||||
// Read 1: MISS (converts). The substitution runs with list A.
|
||||
const first = await client.getPage(PAGE_UUID);
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read converts (miss)");
|
||||
assert.ok(first.data.content.includes("[Alpha](page:" + CHILD_A + ")"), "list A spliced in");
|
||||
assert.ok(!first.data.content.includes("{{SUBPAGES}}"), "placeholder consumed");
|
||||
|
||||
// The subpages change while the PAGE CONTENT/updatedAt do NOT: same conversion
|
||||
// cache key -> a HIT that skips the CPU walk, but the live substitution must
|
||||
// still run on the NEW list B.
|
||||
state.subpages = [{ id: CHILD_B, title: "Beta", hasChildren: false }];
|
||||
|
||||
const second = await client.getPage(PAGE_UUID);
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a conversion HIT");
|
||||
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "no second conversion");
|
||||
|
||||
// The cache did NOT freeze the subpages block: list B is present, list A gone.
|
||||
assert.ok(second.data.content.includes("[Beta](page:" + CHILD_B + ")"), "live list B spliced in on a HIT");
|
||||
assert.ok(!second.data.content.includes("Alpha"), "stale list A is NOT frozen into the output");
|
||||
assert.deepEqual(second.data.subpages, [{ id: CHILD_B, title: "Beta" }], "subpages field reflects list B");
|
||||
});
|
||||
|
||||
test("a cache HIT SKIPS the convertProseMirrorToMarkdown CPU walk (called once across MISS+HIT)", async () => {
|
||||
// The single reason the cache exists: on a hit the expensive PM-tree walk must
|
||||
// NOT run. The miss counter alone can't prove this — a broken hit branch that
|
||||
// re-converted (same output, misses=1) would leave every other assert green.
|
||||
// So spy directly on the conversion seam and assert the call COUNT.
|
||||
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Body text" };
|
||||
const baseURL = await spawn(state);
|
||||
const metrics = {};
|
||||
const client = makeClient(baseURL, metrics);
|
||||
|
||||
// Spy on the seam that wraps convertProseMirrorToMarkdown; it still delegates,
|
||||
// so output stays real and byte-identical — we only count invocations.
|
||||
const spy = mock.method(client, "convertPageMarkdown");
|
||||
|
||||
await client.getPage(PAGE_UUID); // MISS -> converts once
|
||||
assert.equal(spy.mock.callCount(), 1, "the miss converts exactly once");
|
||||
|
||||
await client.getPage(PAGE_UUID); // HIT -> must NOT convert again
|
||||
assert.equal(
|
||||
spy.mock.callCount(),
|
||||
1,
|
||||
"the hit skips the conversion: still exactly one call across MISS+HIT",
|
||||
);
|
||||
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "and it was recorded as a hit");
|
||||
|
||||
spy.mock.restore();
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
// Unit tests for the getPage content-addressed conversion cache (issue #479).
|
||||
// Exercises the GetPageConversionCache class in isolation: key composition,
|
||||
// hit/miss, LRU recency on read, and eviction by BOTH the count cap and the
|
||||
// byte cap. The getPage integration (counter emission, byte-identical output)
|
||||
// is covered separately in test/mock/getpage-conversion-cache.test.mjs.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
GetPageConversionCache,
|
||||
hashConvertOptions,
|
||||
} from "../../build/client/getpage-cache.js";
|
||||
|
||||
test("key: identical (pageId, updatedAt, optionsHash) collapse to one entry", () => {
|
||||
const c = new GetPageConversionCache();
|
||||
const k1 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}");
|
||||
const k2 = GetPageConversionCache.key("uuid-1", "2026-01-01T00:00:00Z", "{}");
|
||||
assert.equal(k1, k2, "same parts -> same key");
|
||||
c.set(k1, "MD-A");
|
||||
assert.equal(c.get(k2), "MD-A", "hit on the identical key");
|
||||
assert.equal(c.size, 1);
|
||||
});
|
||||
|
||||
test("miss after updatedAt changes (precise invalidation)", () => {
|
||||
const c = new GetPageConversionCache();
|
||||
const oldK = GetPageConversionCache.key("uuid-1", "v1", "{}");
|
||||
const newK = GetPageConversionCache.key("uuid-1", "v2", "{}");
|
||||
c.set(oldK, "OLD-MD");
|
||||
assert.equal(c.get(newK), undefined, "a new updatedAt is a fresh key -> miss");
|
||||
assert.equal(c.get(oldK), "OLD-MD", "the old snapshot is still addressable");
|
||||
});
|
||||
|
||||
test("miss after options change (dropResolvedCommentAnchors true vs false)", () => {
|
||||
const c = new GetPageConversionCache();
|
||||
const hDrop = hashConvertOptions({ dropResolvedCommentAnchors: true });
|
||||
const hKeep = hashConvertOptions({ dropResolvedCommentAnchors: false });
|
||||
assert.notEqual(hDrop, hKeep, "different options hash to different keys");
|
||||
const kDrop = GetPageConversionCache.key("uuid-1", "v1", hDrop);
|
||||
const kKeep = GetPageConversionCache.key("uuid-1", "v1", hKeep);
|
||||
c.set(kDrop, "AGENT-MD");
|
||||
assert.equal(
|
||||
c.get(kKeep),
|
||||
undefined,
|
||||
"the export variant must NOT be served the agent variant",
|
||||
);
|
||||
});
|
||||
|
||||
test("hashConvertOptions is order-insensitive and handles empty", () => {
|
||||
assert.equal(
|
||||
hashConvertOptions({ a: 1, b: 2 }),
|
||||
hashConvertOptions({ b: 2, a: 1 }),
|
||||
"key order does not change the hash",
|
||||
);
|
||||
assert.equal(hashConvertOptions(undefined), "{}");
|
||||
assert.equal(hashConvertOptions(null), "{}");
|
||||
assert.equal(hashConvertOptions({}), "{}");
|
||||
});
|
||||
|
||||
test("LRU eviction by COUNT cap evicts the least-recently-used entry", () => {
|
||||
const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 });
|
||||
c.set("k1", "a");
|
||||
c.set("k2", "b");
|
||||
c.set("k3", "c"); // over the count cap -> evict k1 (oldest)
|
||||
assert.equal(c.size, 2);
|
||||
assert.equal(c.get("k1"), undefined, "k1 evicted");
|
||||
assert.equal(c.get("k2"), "b");
|
||||
assert.equal(c.get("k3"), "c");
|
||||
});
|
||||
|
||||
test("a read refreshes recency so the OTHER entry is evicted next", () => {
|
||||
const c = new GetPageConversionCache({ maxEntries: 2, maxBytes: 10 * 1024 * 1024 });
|
||||
c.set("k1", "a");
|
||||
c.set("k2", "b");
|
||||
// Touch k1 so k2 becomes the least-recently-used.
|
||||
assert.equal(c.get("k1"), "a");
|
||||
c.set("k3", "c"); // evicts the LRU, which is now k2 (not k1)
|
||||
assert.equal(c.get("k2"), undefined, "k2 was LRU and got evicted");
|
||||
assert.equal(c.get("k1"), "a", "k1 survived because it was read");
|
||||
assert.equal(c.get("k3"), "c");
|
||||
});
|
||||
|
||||
test("LRU eviction by BYTE cap evicts oldest until under the cap", () => {
|
||||
// Byte cap of 100; each value is 40 bytes -> at most 2 fit (80 < 100 < 120).
|
||||
const big = "x".repeat(40);
|
||||
const c = new GetPageConversionCache({ maxEntries: 50, maxBytes: 100 });
|
||||
c.set("k1", big); // 40
|
||||
c.set("k2", big); // 80
|
||||
assert.equal(c.size, 2);
|
||||
c.set("k3", big); // 120 > 100 -> evict k1 -> 80
|
||||
assert.equal(c.size, 2, "byte cap forced an eviction despite the count cap");
|
||||
assert.equal(c.get("k1"), undefined, "oldest evicted by bytes");
|
||||
assert.equal(c.get("k2"), big);
|
||||
assert.equal(c.get("k3"), big);
|
||||
assert.ok(c.bytes <= 100, "total bytes stays within the cap");
|
||||
});
|
||||
|
||||
test("re-setting an existing key updates value+recency and keeps bytes exact", () => {
|
||||
const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 * 1024 * 1024 });
|
||||
c.set("k1", "short");
|
||||
const b1 = c.bytes;
|
||||
assert.equal(b1, Buffer.byteLength("short", "utf8"));
|
||||
c.set("k1", "a much longer value");
|
||||
assert.equal(c.size, 1, "no duplicate entry");
|
||||
assert.equal(c.bytes, Buffer.byteLength("a much longer value", "utf8"));
|
||||
assert.equal(c.get("k1"), "a much longer value");
|
||||
});
|
||||
|
||||
test("an oversized single entry is still stored (never a permanent miss)", () => {
|
||||
const c = new GetPageConversionCache({ maxEntries: 5, maxBytes: 10 });
|
||||
const big = "y".repeat(1000);
|
||||
c.set("k1", big);
|
||||
assert.equal(c.get("k1"), big, "the page bigger than the cap is still served");
|
||||
assert.equal(c.size, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user