Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51260793c0 |
@@ -463,7 +463,6 @@ 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`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `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
|
||||
|
||||
|
||||
+1
-32
@@ -129,24 +129,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **A drifted comment suggestion can be re-synced instead of failing forever
|
||||
with a 409.** A suggestion whose stored anchor no longer matched the live
|
||||
document used to reject every apply attempt with an unrecoverable conflict; a
|
||||
new resync path re-reads the live anchor so the suggestion applies against the
|
||||
current text, and orphaned anchors (whose marked run was deleted) are
|
||||
reconciled rather than left blocking. (#496)
|
||||
- **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
|
||||
@@ -370,20 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
||||
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
||||
10 min; see `.env.example`). (#489)
|
||||
- **Decisions on comment suggestions now leave a durable audit record.**
|
||||
Applying or dismissing a comment suggestion hard-deletes the (childless)
|
||||
subject comment, so the only surviving trace of who decided what is the audit
|
||||
event — but the audit trail was wired to a Noop service that silently
|
||||
swallowed every event. The trail is now DB-backed, so
|
||||
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
|
||||
comment-decision events) persist to the `audit` table and can be reviewed
|
||||
after the comment is gone. A persistence failure is still swallowed with a
|
||||
warning so it never breaks the originating request. (#496)
|
||||
- **Applying a comment suggestion no longer strips the replaced run's inline
|
||||
formatting.** The suggested text was re-inserted carrying only the comment
|
||||
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
||||
prevailing formatting of the replaced run is now carried onto the applied
|
||||
text. (#496)
|
||||
|
||||
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
"Add": "Add",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
"Add": "Добавить",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
hasAutoReloaded,
|
||||
markAutoReloaded,
|
||||
recordReloadBreadcrumb,
|
||||
} from "@/lib/reload-guard";
|
||||
|
||||
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
|
||||
@@ -28,15 +25,16 @@ 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 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" });
|
||||
// (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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
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,12 +13,6 @@ 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);
|
||||
@@ -28,16 +22,6 @@ 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;
|
||||
@@ -63,16 +47,6 @@ 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 shows a banner and defers the
|
||||
// auto-reload to the next in-app navigation (variant C — avoids reloading a
|
||||
// backgrounded tab that may hold unsaved input) before it hits a stale chunk.
|
||||
newSocket.on("app-version", (payload?: AppVersionSocketPayload) => {
|
||||
triggerGuardedReload(payload?.version);
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log("ws disconnected");
|
||||
newSocket.disconnect();
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "@mantine/spotlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import '@mantine/dates/styles.css';
|
||||
import "@/styles/a11y-overrides.css";
|
||||
import "@/styles/notification-overrides.css";
|
||||
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
@@ -47,7 +48,15 @@ function renderApp() {
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* top-center: toasts sit in the top of the viewport, in the line
|
||||
of sight, and no longer cover centered content (e.g. "Load
|
||||
more"). The below-chrome vertical offset is applied via a
|
||||
position-scoped CSS rule in notification-overrides.css (NOT an
|
||||
inline `style`): Mantine renders all six position containers at
|
||||
once and an inline root style would land on every one, giving the
|
||||
bottom-* containers both top+bottom → full-viewport transparent
|
||||
overlays that swallow clicks. */}
|
||||
<Notifications position="top-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||
404 after a deploy is caught and recovered here instead of
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Toast (Mantine Notification) visibility overrides.
|
||||
* Mantine renders colorless toasts on --mantine-color-body (== the page
|
||||
* background: white in light mode) with a faint shadow, so on white pages the
|
||||
* card has no visible edge. These rules give every toast a type-tinted
|
||||
* background, a WCAG-checked border and a stronger shadow so it separates from
|
||||
* the page. The [data-mantine-color-scheme] + static-class selector (0,2,0)
|
||||
* beats Mantine's own (0,1,0) rules regardless of stylesheet order (Mantine's
|
||||
* bg/border rules wrap the scheme attribute in :where(), so they stay (0,1,0)).
|
||||
* --notification-color is defined on the same element (defaults to primary,
|
||||
* set per `color` prop), so tint/border follow the toast type. This also covers
|
||||
* the loading/import toast (no accent bar, since the spinner takes the icon
|
||||
* slot): its visibility comes from tone + border + shadow + the colored spinner.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||
*
|
||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||
* containers simultaneously (`position` only routes toasts into one via the
|
||||
* store); the root `style` prop would be applied to every one of them by
|
||||
* getStyles("root"). A blanket `top` would land on the bottom-* containers too
|
||||
* (which carry `bottom:16px`) → position:fixed + both edges + height:auto makes
|
||||
* them stretch the full viewport height, and the container root has neither
|
||||
* pointer-events:none nor a background, so those transparent z-10000 overlays
|
||||
* would swallow clicks across the whole page. Restricting to top-* leaves the
|
||||
* bottom containers at height:0.
|
||||
*
|
||||
* Specificity: `.mantine-Notifications-root[data-position^='top']` is (0,2,0)
|
||||
* (class + attribute) and beats Mantine's own top rule
|
||||
* `.m_b37d9ac7:where([data-position='top-center']){top:16px}` which is (0,1,0)
|
||||
* (the :where() contributes 0), regardless of stylesheet order.
|
||||
*/
|
||||
.mantine-Notifications-root[data-position^='top'] {
|
||||
top: 96px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||
/* ~10% type color over white: clearly off-white, text contrast preserved */
|
||||
background-color: color-mix(in srgb, var(--notification-color) 10%, var(--mantine-color-white));
|
||||
/* Border must clear WCAG 3:1 non-text contrast on white. The repo rejects
|
||||
gray-4 for this (a11y-overrides.css); gray-6 base (~3.32:1) darkened by the
|
||||
type color stays >= 3:1. */
|
||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-gray-6));
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='dark'] .mantine-Notification-root {
|
||||
/* Dark page (dark-7/8) vs toast (dark-6) already separate a little; border +
|
||||
shadow carry the type cue here (a 7% dark tint was near-invisible). */
|
||||
background-color: color-mix(in srgb, var(--notification-color) 14%, var(--mantine-color-dark-6));
|
||||
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-dark-3));
|
||||
box-shadow: var(--mantine-shadow-xl);
|
||||
}
|
||||
|
||||
/* Mantine's message-with-title color is gray-6 (#868e96, already only ~3.32:1
|
||||
on white — below AA 4.5:1); the new tint pushes it lower. Bump to gray-7 to
|
||||
keep multi-line colored toasts readable, consistent with the repo's existing
|
||||
WCAG tuning (theme.ts already bumps this same gray-6 up elsewhere). */
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-description[data-with-title] {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import { defineConfig, loadEnv } 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(), "..", "..");
|
||||
@@ -25,32 +24,7 @@ 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,
|
||||
@@ -78,11 +52,10 @@ export default defineConfig(({ mode }) => {
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(appVersion),
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
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({
|
||||
|
||||
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { AuditModule } from './integrations/audit/audit.module';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||
@@ -55,7 +55,7 @@ try {
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
AuditModule,
|
||||
NoopAuditModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
|
||||
@@ -529,107 +529,4 @@ describe('replaceYjsMarkedText', () => {
|
||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||
expect(text.toDelta()).toEqual(before);
|
||||
});
|
||||
|
||||
// #496: apply must NOT silently strip the replaced run's inline formatting.
|
||||
// Build a paragraph and format the marked range with extra marks, then assert
|
||||
// the replacement carries them.
|
||||
function buildFormatted(
|
||||
runs: Array<{ text: string; attrs?: Record<string, any> }>,
|
||||
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||
const ydoc = new Y.Doc();
|
||||
const fragment = ydoc.getXmlFragment('default');
|
||||
const para = new Y.XmlElement('paragraph');
|
||||
fragment.insert(0, [para]);
|
||||
const text = new Y.XmlText();
|
||||
para.insert(0, [text]);
|
||||
text.insert(0, runs.map((r) => r.text).join(''));
|
||||
let offset = 0;
|
||||
for (const run of runs) {
|
||||
if (run.attrs) text.format(offset, run.text.length, run.attrs);
|
||||
offset += run.text.length;
|
||||
}
|
||||
return { fragment, text };
|
||||
}
|
||||
|
||||
it('preserves the original run formatting (bold + link) on the replacement', () => {
|
||||
const { fragment, text } = buildFormatted([
|
||||
{ text: 'see ' },
|
||||
{
|
||||
text: 'old',
|
||||
attrs: {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
bold: true,
|
||||
link: { href: 'https://x.test' },
|
||||
},
|
||||
},
|
||||
{ text: ' end' },
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'new' });
|
||||
// The comment anchor AND the bold/link marks survive the delete+insert.
|
||||
expect(text.toDelta()).toEqual([
|
||||
{ insert: 'see ' },
|
||||
{
|
||||
insert: 'new',
|
||||
attributes: {
|
||||
comment: { commentId: 'c1', resolved: false },
|
||||
bold: true,
|
||||
link: { href: 'https://x.test' },
|
||||
},
|
||||
},
|
||||
{ insert: ' end' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
|
||||
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
|
||||
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
|
||||
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
|
||||
// bold on the result only holds if the code genuinely selects the LONGEST run.
|
||||
const { fragment, text } = buildFormatted([
|
||||
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
|
||||
{
|
||||
text: 'bolded',
|
||||
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Z',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
|
||||
// Two equal-length runs (2 chars each) with different formatting, same
|
||||
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
|
||||
// (italic) prevails over the later bold one.
|
||||
const { fragment, text } = buildFormatted([
|
||||
{
|
||||
text: 'AA',
|
||||
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||
},
|
||||
{
|
||||
text: 'BB',
|
||||
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
|
||||
|
||||
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||
expect(text.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'Z',
|
||||
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,10 +145,6 @@ type MarkedSegment = {
|
||||
length: number;
|
||||
text: string;
|
||||
markAttrs: Record<string, any>;
|
||||
// The FULL attribute set of this delta run — the `comment` mark plus any
|
||||
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
|
||||
// original run's formatting onto the replacement instead of dropping it.
|
||||
attributes: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -206,7 +202,6 @@ export function replaceYjsMarkedText(
|
||||
length,
|
||||
text: insert,
|
||||
markAttrs: markAttr,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
offset += length;
|
||||
@@ -256,25 +251,15 @@ export function replaceYjsMarkedText(
|
||||
return { applied: false, currentText: joinedText };
|
||||
}
|
||||
|
||||
// 3. All guards passed: delete the marked run and re-insert newText at the
|
||||
// same offset. Atomic within the caller's transaction.
|
||||
// 3. All guards passed: delete the marked run and re-insert newText with the
|
||||
// same comment attributes at the same offset. Atomic within the caller's
|
||||
// transaction.
|
||||
const start = segments[0].offset;
|
||||
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||
|
||||
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
|
||||
// with only the `comment` mark silently dropped bold/italic/code/link of the
|
||||
// replaced text. Yjs applies one flat attribute set to the whole insert, so
|
||||
// when the marked run mixes formatting we pick the DOMINANT segment (the one
|
||||
// covering the most characters) and apply its attributes — a v1 that preserves
|
||||
// the common single-format case exactly and, for a mixed run, keeps the
|
||||
// prevailing style rather than losing all of it. `attributes` already carries
|
||||
// the `comment` mark (every collected segment is filtered on it above), so the
|
||||
// anchor is preserved by copying the run's attribute set verbatim.
|
||||
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
|
||||
const insertAttrs = { ...dominant.attributes };
|
||||
const markAttrs = segments[0].markAttrs;
|
||||
|
||||
node.delete(start, len);
|
||||
node.insert(start, newText, insertAttrs);
|
||||
node.insert(start, newText, { comment: markAttrs });
|
||||
|
||||
return { applied: true, currentText: newText };
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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('');
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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,4 +3,3 @@ export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
export * from './security-headers';
|
||||
export * from './client-version';
|
||||
|
||||
@@ -16,7 +16,6 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
||||
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||
import { ResyncSuggestionAnchorDto } from './dto/resync-suggestion-anchor.dto';
|
||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
@@ -236,39 +235,6 @@ export class CommentController {
|
||||
return this.commentService.applySuggestion(comment, user, provenance);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('resync-suggestion-anchor')
|
||||
async resyncSuggestionAnchor(
|
||||
@Body() dto: ResyncSuggestionAnchorDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const page = await this.pageRepo.findById(comment.pageId);
|
||||
if (!page || page.deletedAt) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Authorize BEFORE revealing structural detail (mirrors apply/dismiss).
|
||||
// Re-anchoring does NOT change the page text — it only corrects the stored
|
||||
// selection metadata — so the page-level gate is comment access. The service
|
||||
// further restricts it to the suggestion's own author.
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
return this.commentService.resyncSuggestionAnchor(
|
||||
comment,
|
||||
dto.selection,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('dismiss-suggestion')
|
||||
async dismissSuggestion(
|
||||
|
||||
@@ -146,19 +146,11 @@ describe('CommentService — applySuggestion', () => {
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||
);
|
||||
// #496: hard-deleted row → the audit payload is the only surviving record.
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
metadata: expect.objectContaining({
|
||||
pageId: 'page-1',
|
||||
suggestedText: 'new text',
|
||||
selection: 'old text',
|
||||
commentAuthor: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
@@ -197,25 +189,17 @@ describe('CommentService — applySuggestion', () => {
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
|
||||
// NOT deleted.
|
||||
// NOT deleted; broadcast an update, not a deletion.
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// #496 dedup: resolveComment broadcasts `commentResolved` with the enriched
|
||||
// row; finalize must NOT ALSO emit a redundant `commentUpdated`. So the
|
||||
// thread receives exactly ONE resolve broadcast and no update broadcast.
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentResolved', comment: UPDATED }),
|
||||
);
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated' }),
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
@@ -227,36 +211,6 @@ describe('CommentService — applySuggestion', () => {
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('re-entry: already applied+resolved WITH replies → emits commentUpdated (dedup does not over-suppress)', async () => {
|
||||
// suggestionAppliedAt set → idempotent finalize; resolvedAt set → resolveComment
|
||||
// is skipped, so there is NO commentResolved broadcast. The applied-stamp state
|
||||
// must still reach clients via a single commentUpdated.
|
||||
const { service, wsService } = makeService(
|
||||
{ applied: false, currentText: 'new text' },
|
||||
true,
|
||||
);
|
||||
|
||||
await service.applySuggestion(
|
||||
suggestionComment({
|
||||
suggestionAppliedAt: new Date(),
|
||||
resolvedAt: new Date(),
|
||||
}),
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
);
|
||||
// Nothing resolved this time (already resolved) → no resolve broadcast.
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentResolved' }),
|
||||
);
|
||||
});
|
||||
|
||||
// --- error / rejection branches -----------------------------------------
|
||||
|
||||
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
||||
|
||||
@@ -107,21 +107,11 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||
);
|
||||
// #496: the row is hard-deleted, so the audit payload must carry the
|
||||
// decision's substance (what was suggested, the anchored text, who authored
|
||||
// it, who decided) — it is the only surviving record.
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
metadata: expect.objectContaining({
|
||||
pageId: 'page-1',
|
||||
suggestedText: 'new text',
|
||||
selection: 'old text',
|
||||
commentAuthor: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
|
||||
/**
|
||||
* Coverage for CommentService.resyncSuggestionAnchor (#496): re-anchoring a
|
||||
* suggestion's stored selection (== apply-time expectedText) to the live-doc
|
||||
* substring. The service is built directly with jest-mocked deps (the
|
||||
* @InjectQueue tokens can't be resolved by Test.createTestingModule — see the
|
||||
* sibling specs).
|
||||
*/
|
||||
describe('CommentService — resyncSuggestionAnchor', () => {
|
||||
const UPDATED = { id: 'c-1', selection: 'new anchor', __updated: true } as any;
|
||||
|
||||
function makeService() {
|
||||
const commentRepo: any = {
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
findById: jest.fn(async () => UPDATED),
|
||||
};
|
||||
const service = new CommentService(
|
||||
commentRepo,
|
||||
{} as any,
|
||||
{ emitCommentEvent: jest.fn() } as any,
|
||||
{} as any,
|
||||
{ add: jest.fn() } as any,
|
||||
{ add: jest.fn() } as any,
|
||||
{ log: jest.fn() } as any,
|
||||
);
|
||||
return { service, commentRepo };
|
||||
}
|
||||
|
||||
const suggestion = (over?: Partial<any>): any => ({
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
parentCommentId: null,
|
||||
selection: 'old anchor',
|
||||
suggestedText: 'new text',
|
||||
suggestionAppliedAt: null,
|
||||
resolvedAt: null,
|
||||
...over,
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
it('persists the new selection and returns the enriched comment', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
const out = await service.resyncSuggestionAnchor(
|
||||
suggestion(),
|
||||
'new anchor',
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(commentRepo.updateComment).toHaveBeenCalledWith(
|
||||
{ selection: 'new anchor' },
|
||||
'c-1',
|
||||
);
|
||||
expect(out).toBe(UPDATED);
|
||||
});
|
||||
|
||||
it('is idempotent: no write when the anchor already matches', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
|
||||
const out = await service.resyncSuggestionAnchor(
|
||||
suggestion({ selection: 'same' }),
|
||||
'same',
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
||||
expect(out).toEqual(suggestion({ selection: 'same' }));
|
||||
});
|
||||
|
||||
it('rejects a non-author (only the suggestion owner may re-anchor)', async () => {
|
||||
const { service, commentRepo } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(suggestion(), 'new anchor', user({ id: 'other' })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a reply / a comment with no suggestion', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ parentCommentId: 'p-1' }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ suggestedText: null }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects re-anchoring an already applied or resolved suggestion', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ suggestionAppliedAt: new Date() }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ resolvedAt: new Date() }),
|
||||
'x',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a no-op selection equal to the suggested text', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.resyncSuggestionAnchor(
|
||||
suggestion({ suggestedText: 'new text' }),
|
||||
'new text',
|
||||
user(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -370,76 +370,6 @@ export class CommentService {
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sync a suggestion's stored `selection` (== apply-time expectedText) to the
|
||||
* RAW substring the inline mark actually covers in the LIVE document (#496).
|
||||
*
|
||||
* The MCP client creates the comment from a DEBOUNCED REST snapshot, then
|
||||
* anchors the mark in the live collab doc. When the two disagree (the doc moved
|
||||
* on in the debounce window) the stored selection no longer equals the marked
|
||||
* text, so EVERY apply 409s ("the commented text changed"). After anchoring the
|
||||
* client re-reads the exact marked substring and calls this to store it, making
|
||||
* apply's strict equality hold.
|
||||
*
|
||||
* Only meaningful for an un-settled top-level suggestion authored by the
|
||||
* caller: applying/resolving freezes the anchor, and a reply-carrying thread is
|
||||
* preserved rather than mutated. The new text must still differ from the
|
||||
* suggestion (else "apply" would be a no-op), preserving create()'s invariant.
|
||||
*/
|
||||
async resyncSuggestionAnchor(
|
||||
comment: Comment,
|
||||
selection: string,
|
||||
user: User,
|
||||
): Promise<Comment> {
|
||||
if (comment.creatorId !== user.id) {
|
||||
throw new ForbiddenException(
|
||||
'You can only re-anchor your own suggestion',
|
||||
);
|
||||
}
|
||||
if (comment.parentCommentId) {
|
||||
throw new BadRequestException(
|
||||
'Only a top-level comment can carry a suggested edit',
|
||||
);
|
||||
}
|
||||
if (!comment.suggestedText) {
|
||||
throw new BadRequestException('This comment has no suggested edit');
|
||||
}
|
||||
// A settled suggestion's anchor is frozen: re-anchoring an applied/resolved
|
||||
// thread is meaningless and could resurrect a stale expectedText.
|
||||
if (comment.suggestionAppliedAt || comment.resolvedAt) {
|
||||
throw new BadRequestException(
|
||||
'Cannot re-anchor a suggestion that was already applied or resolved',
|
||||
);
|
||||
}
|
||||
const trimmed = selection.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new BadRequestException('The re-anchored selection cannot be empty');
|
||||
}
|
||||
// Same no-op guard as create(): the suggestion must differ from the text it
|
||||
// replaces, or apply becomes indistinguishable from already-applied.
|
||||
if (trimmed === comment.suggestedText.trim()) {
|
||||
throw new BadRequestException(
|
||||
'A suggested edit must differ from the selected text',
|
||||
);
|
||||
}
|
||||
|
||||
// Idempotent: nothing to persist when the anchor already matches.
|
||||
if (comment.selection === selection) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
await this.commentRepo.updateComment({ selection }, comment.id);
|
||||
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
// Re-anchoring only corrects stored metadata; it does not change the page
|
||||
// text or the comment body, so no ws broadcast / notification is warranted.
|
||||
return updatedComment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the suggested edit carried by a top-level inline comment: atomically
|
||||
* replace the text under the comment mark in the collaborative document with
|
||||
@@ -594,7 +524,7 @@ export class CommentService {
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
return { ...updatedComment, outcome: 'resolved' };
|
||||
}
|
||||
@@ -608,7 +538,7 @@ export class CommentService {
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
@@ -647,10 +577,8 @@ export class CommentService {
|
||||
|
||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||
// broadcast and the resolve notification. Stay defensive on re-entry.
|
||||
let didResolveBroadcast = false;
|
||||
if (!comment.resolvedAt) {
|
||||
await this.resolveComment(comment, true, user, provenance);
|
||||
didResolveBroadcast = true;
|
||||
}
|
||||
|
||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||
@@ -658,27 +586,18 @@ export class CommentService {
|
||||
includeResolvedBy: true,
|
||||
});
|
||||
|
||||
// #496 dedup: resolveComment already broadcast `commentResolved` carrying
|
||||
// the fully-enriched row (the applied stamps were persisted above, before
|
||||
// that call, so its re-read reflects them). Emitting `commentUpdated` here
|
||||
// too made the client receive TWO events for one apply. Broadcast the
|
||||
// update ONLY when we did NOT resolve — i.e. the rare re-entry on an
|
||||
// already-resolved thread, where the applied-stamp change still needs a
|
||||
// broadcast and resolveComment did not run.
|
||||
if (!didResolveBroadcast) {
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment: updatedComment,
|
||||
});
|
||||
}
|
||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||
operation: 'commentUpdated',
|
||||
pageId: comment.pageId,
|
||||
comment: updatedComment,
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
|
||||
return { ...updatedComment, outcome: 'resolved' };
|
||||
@@ -697,7 +616,7 @@ export class CommentService {
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: comment.id,
|
||||
spaceId: comment.spaceId,
|
||||
metadata: this.suggestionAuditMetadata(comment, user),
|
||||
metadata: { pageId: comment.pageId },
|
||||
});
|
||||
|
||||
return settled;
|
||||
@@ -708,17 +627,14 @@ export class CommentService {
|
||||
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
|
||||
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
|
||||
*
|
||||
* ORDER MATTERS (updated #399 → #496): what runs FIRST and FATALLY here is the
|
||||
* mark-removal ENQUEUE (a fast, durable Redis add), NOT the mark op itself.
|
||||
* deleteCommentMark awaits only the enqueue, so a failed add throws BEFORE the
|
||||
* irreversible row delete — the row + mark stay consistent and the operation is
|
||||
* repeatable. The actual anchor strip then runs off the HTTP path in the worker
|
||||
* (idempotent, 3 retries). Only an EXHAUSTED-retries job could leave the doc
|
||||
* with an orphan anchor pointing at a hard-deleted comment (the data-integrity
|
||||
* bug #329 targets); that residual divergence is now self-healed by the
|
||||
* resolve/unresolve mark worker, which strips an orphan mark whenever its
|
||||
* comment row is gone (#496), and it is meanwhile VISIBLE via BullMQ failed-job
|
||||
* metrics rather than a silently-swallowed warn.
|
||||
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
|
||||
* applySuggestion, which mutates the doc before writing the DB). The row
|
||||
* delete is irreversible, so if the mark removal fails — including the
|
||||
* COLLAB_DISABLE_REDIS "no live instance" hard-error — we must NOT delete the
|
||||
* row and report success, or the document is left with a permanent orphan
|
||||
* anchor pointing at a comment that no longer exists (the exact data-integrity
|
||||
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
|
||||
* then repeatable with row + mark still consistent.
|
||||
*
|
||||
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
|
||||
* removal, so a reply can land in that window. `comments.parent_comment_id` is
|
||||
@@ -816,27 +732,6 @@ export class CommentService {
|
||||
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the audit metadata for a suggestion apply/dismiss decision (#496).
|
||||
* The subject comment is HARD-DELETED on the childless path, so the audit row
|
||||
* is the only surviving record — capture the decision's substance (what was
|
||||
* suggested, the anchored text it replaced, who authored it, who decided)
|
||||
* before the row can vanish. `decidedBy` is the acting user; `commentAuthor`
|
||||
* is the suggestion's creator.
|
||||
*/
|
||||
private suggestionAuditMetadata(
|
||||
comment: Comment,
|
||||
user: User,
|
||||
): Record<string, any> {
|
||||
return {
|
||||
pageId: comment.pageId,
|
||||
suggestedText: comment.suggestedText ?? null,
|
||||
selection: comment.selection ?? null,
|
||||
commentAuthor: comment.creatorId ?? null,
|
||||
decidedBy: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
private async queueCommentNotification(
|
||||
content: any,
|
||||
oldMentionIds: string[],
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* #496: after the MCP client anchors a suggestion in the LIVE collab doc, it
|
||||
* re-reads the exact substring under the new mark and syncs it here as the
|
||||
* comment's stored `selection` (== apply-time expectedText). Fixes the perpetual
|
||||
* 409 where expectedText came from a debounced REST snapshot while the mark sat
|
||||
* in the live doc.
|
||||
*/
|
||||
export class ResyncSuggestionAnchorDto {
|
||||
@IsUUID()
|
||||
commentId: string;
|
||||
|
||||
// The raw substring the mark now covers in the live document. Bounded like the
|
||||
// create-time selection (2000) so a legitimate anchored span is never cut.
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
selection: string;
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AUDIT_SERVICE } from './audit.service';
|
||||
import { DatabaseAuditService } from './database-audit.service';
|
||||
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
|
||||
|
||||
// #496: bind the audit token to a real DB-backed trail (was NoopAuditService,
|
||||
// which silently dropped every event). Kysely (@Global DatabaseModule) and
|
||||
// ClsService (@Global ClsModule) are both globally available, so this module
|
||||
// needs no extra imports.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: AUDIT_SERVICE,
|
||||
useClass: DatabaseAuditService,
|
||||
useClass: NoopAuditService,
|
||||
},
|
||||
],
|
||||
exports: [AUDIT_SERVICE],
|
||||
})
|
||||
export class AuditModule {}
|
||||
export class NoopAuditModule {}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { DatabaseAuditService } from './database-audit.service';
|
||||
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
|
||||
/**
|
||||
* Observable-property coverage for the DB-backed audit trail (#496): every
|
||||
* assertion pins what actually reaches the `audit` table (or that nothing does),
|
||||
* driven through a chainable Kysely mock that captures the inserted rows.
|
||||
*/
|
||||
describe('DatabaseAuditService', () => {
|
||||
function makeService(clsContext: any) {
|
||||
const inserted: any[] = [];
|
||||
const updated: any[] = [];
|
||||
let failNextInsert = false;
|
||||
|
||||
const db: any = {
|
||||
insertInto: jest.fn(() => ({
|
||||
values: jest.fn((rows: any) => ({
|
||||
execute: jest.fn(async () => {
|
||||
if (failNextInsert) {
|
||||
failNextInsert = false;
|
||||
throw new Error('boom');
|
||||
}
|
||||
inserted.push(rows);
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
updateTable: jest.fn(() => ({
|
||||
set: jest.fn((patch: any) => ({
|
||||
where: jest.fn(() => ({
|
||||
execute: jest.fn(async () => {
|
||||
updated.push(patch);
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
|
||||
const cls: any = {
|
||||
get: jest.fn((key: string) => store[key]),
|
||||
set: jest.fn((key: string, val: any) => {
|
||||
store[key] = val;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new DatabaseAuditService(db, cls);
|
||||
return {
|
||||
service,
|
||||
inserted,
|
||||
updated,
|
||||
cls,
|
||||
store,
|
||||
failInsert: () => {
|
||||
failNextInsert = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const applyPayload = () => ({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
|
||||
});
|
||||
|
||||
const ctx = (over?: any) => ({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'u-2',
|
||||
actorType: 'user',
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: 'jest',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('log() persists a row with the CLS context merged onto the payload', async () => {
|
||||
const { service, inserted } = makeService(ctx());
|
||||
service.log(applyPayload());
|
||||
// log() is fire-and-forget; flush the microtask queue.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
workspaceId: 'ws-1',
|
||||
actorId: 'u-2',
|
||||
actorType: 'user',
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
ipAddress: '10.0.0.1',
|
||||
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('log() is a no-op when there is no workspace in scope', async () => {
|
||||
const { service, inserted } = makeService(ctx({ workspaceId: null }));
|
||||
service.log(applyPayload());
|
||||
await Promise.resolve();
|
||||
expect(inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
|
||||
const { service, inserted } = makeService(ctx());
|
||||
service.log({
|
||||
event: AuditEvent.COMMENT_CREATED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a failed insert is swallowed with a warn and floats no rejection (audit is a side-record)', async () => {
|
||||
// This pins the load-bearing swallow inside persist(). Because log() is
|
||||
// fire-and-forget (`void this.persist(...)`), it always returns synchronously
|
||||
// without throwing — so `not.toThrow()` alone would stay green even if the
|
||||
// try/catch were removed. We instead observe the two effects the catch is
|
||||
// responsible for: a warn IS emitted, and NO unhandled rejection floats.
|
||||
// Removing persist()'s try/catch reddens both assertions (warn count 0 + a
|
||||
// captured rejection).
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as any);
|
||||
const rejections: unknown[] = [];
|
||||
const onRejection = (err: unknown) => rejections.push(err);
|
||||
process.on('unhandledRejection', onRejection);
|
||||
try {
|
||||
const { service, failInsert } = makeService(ctx());
|
||||
failInsert();
|
||||
expect(() => service.log(applyPayload())).not.toThrow();
|
||||
// Flush microtasks so the rejected insert settles, then give any floated
|
||||
// rejection a macrotask tick to be reported by the runtime.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain(
|
||||
'Failed to persist audit event',
|
||||
);
|
||||
expect(rejections).toHaveLength(0);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onRejection);
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logWithContext() persists with an explicit (non-request) context', async () => {
|
||||
const { service, inserted } = makeService(undefined);
|
||||
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0].actorType).toBe('system');
|
||||
expect(inserted[0].workspaceId).toBe('ws-1');
|
||||
});
|
||||
|
||||
it('logBatchWithContext() inserts only non-excluded events', async () => {
|
||||
const { service, inserted } = makeService(undefined);
|
||||
service.logBatchWithContext(
|
||||
[
|
||||
applyPayload(),
|
||||
{
|
||||
event: AuditEvent.COMMENT_CREATED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-2',
|
||||
},
|
||||
],
|
||||
ctx() as any,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Batch is a single insert call carrying only the applied event.
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(inserted[0]).toHaveLength(1);
|
||||
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
|
||||
});
|
||||
|
||||
it('setActorId / setActorType mutate the ambient CLS context', () => {
|
||||
const { service, store } = makeService(ctx({ actorId: null }));
|
||||
service.setActorId('u-9');
|
||||
service.setActorType('api_key');
|
||||
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
|
||||
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
|
||||
});
|
||||
|
||||
it('updateRetention() writes the workspace retention window', async () => {
|
||||
const { service, updated } = makeService(ctx());
|
||||
await service.updateRetention('ws-1', 30);
|
||||
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
AuditContext,
|
||||
AUDIT_CONTEXT_KEY,
|
||||
} from '../../common/middlewares/audit-context.middleware';
|
||||
import {
|
||||
AuditLogPayload,
|
||||
ActorType,
|
||||
EXCLUDED_AUDIT_EVENTS,
|
||||
} from '../../common/events/audit-events';
|
||||
import { AuditLogContext, IAuditService } from './audit.service';
|
||||
|
||||
/**
|
||||
* Minimal DB-backed audit trail (#496). Replaces NoopAuditService so that
|
||||
* decision-bearing events — notably comment.suggestion_applied /
|
||||
* comment.suggestion_dismissed, whose subject comment is HARD-DELETED on the
|
||||
* childless path — leave a durable record of who decided what. Without this the
|
||||
* events were emitted (comment.service / *.controller) but swallowed, so an
|
||||
* applied/dismissed suggestion was unrecoverable once the row was gone.
|
||||
*
|
||||
* Rows land in the pre-existing `audit` table (migration 20260228T223532). The
|
||||
* per-request actor/workspace/ip come from the CLS AuditContext populated by
|
||||
* AuditContextMiddleware + AuditActorInterceptor; callers that run OUTSIDE a
|
||||
* request (queue workers, imports) pass an explicit context via
|
||||
* logWithContext / logBatchWithContext.
|
||||
*
|
||||
* Audit is a side-record: a write failure MUST NOT break the originating
|
||||
* request, so every persistence path swallows its error with a warn. Events in
|
||||
* EXCLUDED_AUDIT_EVENTS (high-volume/low-signal) are dropped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseAuditService implements IAuditService {
|
||||
private readonly logger = new Logger(DatabaseAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Persist a single event using the ambient request-scoped AuditContext. A
|
||||
* no-op when there is no workspace in scope (the table's workspace_id is NOT
|
||||
* NULL) or the event is excluded. Fire-and-forget: the returned promise is not
|
||||
* awaited by hot callers, and its rejection is swallowed here.
|
||||
*/
|
||||
log(payload: AuditLogPayload): void {
|
||||
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (!context?.workspaceId) {
|
||||
// No workspace in scope — nothing we can attribute the row to. This is
|
||||
// expected for events emitted outside an HTTP request; those callers must
|
||||
// use logWithContext instead.
|
||||
return;
|
||||
}
|
||||
void this.persist(payload, {
|
||||
workspaceId: context.workspaceId,
|
||||
actorId: context.actorId ?? undefined,
|
||||
actorType: context.actorType,
|
||||
ipAddress: context.ipAddress ?? undefined,
|
||||
userAgent: context.userAgent ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a single event with an explicit (non-request) context. */
|
||||
logWithContext(payload: AuditLogPayload, context: AuditLogContext): void {
|
||||
if (!context?.workspaceId) return;
|
||||
void this.persist(payload, context);
|
||||
}
|
||||
|
||||
/** Persist a batch of events sharing one explicit context (imports). */
|
||||
logBatchWithContext(
|
||||
payloads: AuditLogPayload[],
|
||||
context: AuditLogContext,
|
||||
): void {
|
||||
if (!context?.workspaceId || payloads.length === 0) return;
|
||||
const rows = payloads
|
||||
.filter((p) => !EXCLUDED_AUDIT_EVENTS.has(p.event))
|
||||
.map((p) => this.toRow(p, context));
|
||||
if (rows.length === 0) return;
|
||||
this.db
|
||||
.insertInto('audit')
|
||||
.values(rows)
|
||||
.execute()
|
||||
.catch((err: any) =>
|
||||
this.logger.warn(`Failed to persist ${rows.length} audit events: ${err?.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the ambient request actor (e.g. after login resolves the user). */
|
||||
setActorId(actorId: string): void {
|
||||
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (context) {
|
||||
context.actorId = actorId;
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the ambient request actor type (user | system | api_key). */
|
||||
setActorType(actorType: ActorType): void {
|
||||
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
if (context) {
|
||||
context.actorType = actorType;
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a workspace's audit-log retention window (days). */
|
||||
async updateRetention(
|
||||
workspaceId: string,
|
||||
retentionDays: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.updateTable('workspaces')
|
||||
.set({ auditRetentionDays: retentionDays })
|
||||
.where('id', '=', workspaceId)
|
||||
.execute();
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
`Failed to update audit retention for workspace ${workspaceId}: ${err?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async persist(
|
||||
payload: AuditLogPayload,
|
||||
context: AuditLogContext,
|
||||
): Promise<void> {
|
||||
if (EXCLUDED_AUDIT_EVENTS.has(payload.event)) return;
|
||||
try {
|
||||
await this.db
|
||||
.insertInto('audit')
|
||||
.values(this.toRow(payload, context))
|
||||
.execute();
|
||||
} catch (err: any) {
|
||||
// Audit is a side-record; never let a failed write surface to the caller.
|
||||
this.logger.warn(
|
||||
`Failed to persist audit event ${payload.event}: ${err?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toRow(payload: AuditLogPayload, context: AuditLogContext) {
|
||||
return {
|
||||
workspaceId: context.workspaceId,
|
||||
actorId: context.actorId ?? null,
|
||||
actorType: context.actorType ?? 'user',
|
||||
event: payload.event,
|
||||
resourceType: payload.resourceType,
|
||||
resourceId: payload.resourceId ?? null,
|
||||
spaceId: payload.spaceId ?? null,
|
||||
// jsonb columns: node-postgres serializes plain objects to JSON.
|
||||
changes: payload.changes ? (payload.changes as any) : null,
|
||||
metadata: payload.metadata ? (payload.metadata as any) : null,
|
||||
ipAddress: context.ipAddress ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-8
@@ -139,19 +139,13 @@ describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcile (#496): comment row vanished → strips the orphan anchor mark', async () => {
|
||||
it('skips (no throw) when the comment row has vanished', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
commentRepo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
|
||||
).resolves.toBeUndefined();
|
||||
// A resolve/unresolve mark job whose comment row is gone leaves a silent
|
||||
// orphan; the worker self-heals by stripping the anchor instead of returning.
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', user: { id: 'user-1' } },
|
||||
);
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,8 +95,7 @@ export class GeneralQueueProcessor
|
||||
* #399: apply a comment's inline-mark mirror in the collab Y.Doc, off the HTTP
|
||||
* critical path. Runs the SAME gateway path the synchronous comment.service
|
||||
* code used (byte-identical mark op):
|
||||
* - resolve / unresolve → resolveCommentMark (flip the `resolved` attribute),
|
||||
* OR strip an orphan anchor when the comment row has vanished (#496);
|
||||
* - resolve / unresolve → resolveCommentMark (flip the `resolved` attribute);
|
||||
* - delete → deleteCommentMark (strip the ephemeral-suggestion anchor #329).
|
||||
* The op is idempotent, so a BullMQ retry is safe. Throwing propagates to
|
||||
* WorkerHost → the job is retried and, on exhaustion, surfaces in failed-job
|
||||
@@ -134,18 +133,7 @@ export class GeneralQueueProcessor
|
||||
// of this resolve), skip it rather than flip the mark to a stale state.
|
||||
const comment = await this.commentRepo.findById(commentId);
|
||||
if (!comment) {
|
||||
// #496 reconcile: the comment row is GONE (e.g. an ephemeral apply/dismiss
|
||||
// hard-deleted it while this resolve/unresolve mark job sat in the queue),
|
||||
// but its inline anchor may still live in the doc — a silent orphan mark
|
||||
// pointing at a comment that no longer exists. Self-heal by stripping it
|
||||
// instead of just returning: this closes the divergence the fire-and-forget
|
||||
// resolve/unresolve enqueue (comment.service resolveComment) could leave.
|
||||
// Idempotent — deleteCommentMark on an already-absent mark is a no-op.
|
||||
await this.getCollaborationGateway().handleYjsEvent(
|
||||
'deleteCommentMark',
|
||||
documentName,
|
||||
{ commentId, user },
|
||||
);
|
||||
// The comment vanished (e.g. hard-deleted) → nothing left to mirror.
|
||||
return;
|
||||
}
|
||||
const wantResolved = action === 'resolve';
|
||||
|
||||
@@ -4,7 +4,6 @@ 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.
|
||||
@@ -57,7 +56,14 @@ export class StaticModule implements OnModuleInit {
|
||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||
const app = httpAdapter.getInstance();
|
||||
|
||||
const clientDistPath = resolveClientDistPath();
|
||||
const clientDistPath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'client/dist',
|
||||
);
|
||||
|
||||
const indexFilePath = join(clientDistPath, 'index.html');
|
||||
|
||||
|
||||
@@ -9,14 +9,10 @@ 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 { Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { OnModuleDestroy } 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({
|
||||
@@ -24,40 +20,17 @@ import * as cookie from 'cookie';
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class WsGateway
|
||||
implements
|
||||
OnGatewayConnection,
|
||||
OnGatewayInit,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy
|
||||
implements OnGatewayConnection, OnGatewayInit, 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);
|
||||
}
|
||||
@@ -82,14 +55,6 @@ 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();
|
||||
|
||||
@@ -450,12 +450,6 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
|
||||
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||
// LIVE document (the pre-check page is not in scope there).
|
||||
let liveNotFoundError: Error | null = null;
|
||||
// #496: the RAW substring the mark actually covers in the LIVE doc. The
|
||||
// stored selection (payload.selection) came from a DEBOUNCED REST snapshot,
|
||||
// which can differ from the live doc — and apply compares the marked live
|
||||
// text to the stored selection strictly, so a stale snapshot 409s on EVERY
|
||||
// apply. Captured here (same doc version the mark is set in) and synced below.
|
||||
let liveAnchoredSelection: string | null = null;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
@@ -495,12 +489,6 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
|
||||
}
|
||||
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
|
||||
anchored = true;
|
||||
// For a suggestion, re-read the exact substring now under the mark
|
||||
// (the mark is an attribute, so it does not change the raw text) to
|
||||
// sync as the stored expectedText after the mutation resolves.
|
||||
if (hasSuggestion) {
|
||||
liveAnchoredSelection = getAnchoredText(doc, selection as string);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
// Selection text not found in the LIVE document: abort the write. The
|
||||
@@ -539,36 +527,6 @@ export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(
|
||||
);
|
||||
}
|
||||
|
||||
// #496: sync the stored selection (== apply-time expectedText) to the RAW
|
||||
// substring the mark actually covers in the LIVE doc when it diverged from
|
||||
// the debounced REST snapshot we stored at create time. Without this, apply
|
||||
// strictly compares the marked live text to a stale stored selection and
|
||||
// 409s every time. Best-effort: the comment is already correctly anchored, so
|
||||
// a resync failure must NOT roll it back — it only risks a later apply 409,
|
||||
// which we surface as a soft warning.
|
||||
if (
|
||||
hasSuggestion &&
|
||||
liveAnchoredSelection != null &&
|
||||
liveAnchoredSelection !== payload.selection
|
||||
) {
|
||||
try {
|
||||
await this.client.post("/comments/resync-suggestion-anchor", {
|
||||
commentId: newCommentId,
|
||||
selection: liveAnchoredSelection,
|
||||
});
|
||||
// Reflect the corrected anchor in the returned comment.
|
||||
if (result.data) result.data.selection = liveAnchoredSelection;
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error("Failed to resync suggestion anchor:", e);
|
||||
}
|
||||
result.warning =
|
||||
"The suggestion was anchored, but its stored selection could not be " +
|
||||
"synced to the live document; applying it may report a conflict if the " +
|
||||
"text changed. Re-create the suggestion if Apply fails.";
|
||||
}
|
||||
}
|
||||
|
||||
// Soft warning (like editPageText): the selection only matched after
|
||||
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||
if (anchorNormalized) {
|
||||
|
||||
@@ -553,189 +553,6 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
|
||||
assert.equal(createPayload.suggestedText, "goodbye");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8b) #496: the DEBOUNCED REST snapshot (pages/info) DIFFERS from the LIVE collab
|
||||
// doc (mutatePage) — the doc moved on in the debounce window. The stored
|
||||
// selection is captured from the snapshot at create time, but the mark is set
|
||||
// in the live doc, so apply would 409 forever. After anchoring, the client
|
||||
// re-reads the RAW substring under the mark from the LIVE doc and POSTs it to
|
||||
// /comments/resync-suggestion-anchor so the stored expectedText matches.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestion: re-syncs the stored selection to the LIVE doc substring when the REST snapshot lagged", async () => {
|
||||
let createPayload = null;
|
||||
let resyncPayload = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
// DEBOUNCED snapshot: ASCII quotes (stale — the live doc has since been
|
||||
// typographically corrected).
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "33333333-3333-3333-3333-333333333333",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: 'he said "hello" loudly' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
createPayload = JSON.parse(raw);
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "cmt-resync-1",
|
||||
content: createPayload.content,
|
||||
selection: createPayload.selection,
|
||||
suggestedText: createPayload.suggestedText,
|
||||
type: createPayload.type,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/resync-suggestion-anchor") {
|
||||
resyncPayload = JSON.parse(raw);
|
||||
sendJson(res, 200, { data: { id: "cmt-resync-1" } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
class TestClient extends DocmostClient {
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
async resolvePageId() {
|
||||
return "33333333-3333-3333-3333-333333333333";
|
||||
}
|
||||
async mutatePage(pageId, collabToken, apiUrl, transform) {
|
||||
// LIVE doc: SMART quotes (what the mark is actually set over).
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "he said “hello” loudly" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = transform(doc);
|
||||
return { doc: out, verify: { ok: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const client = new TestClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.createComment(
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
"please change",
|
||||
"inline",
|
||||
'"hello"', // ASCII quotes
|
||||
undefined,
|
||||
"goodbye",
|
||||
);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.anchored, true);
|
||||
// Create stored the STALE snapshot substring (ASCII quotes).
|
||||
assert.equal(createPayload.selection, '"hello"');
|
||||
// …then the client re-synced to the LIVE marked substring (smart quotes).
|
||||
assert.ok(resyncPayload, "/comments/resync-suggestion-anchor must be called");
|
||||
assert.equal(resyncPayload.commentId, "cmt-resync-1");
|
||||
assert.equal(resyncPayload.selection, "“hello”");
|
||||
// The returned comment reflects the corrected anchor.
|
||||
assert.equal(result.data.selection, "“hello”");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8c) #496: when the REST snapshot and the LIVE doc AGREE, no resync round-trip
|
||||
// is made (the common case must stay a single write).
|
||||
// -----------------------------------------------------------------------------
|
||||
test("suggestion: no resync call when the snapshot already matches the live doc", async () => {
|
||||
let resyncCalls = 0;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "44444444-4444-4444-4444-444444444444",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
const p = JSON.parse(raw);
|
||||
sendJson(res, 200, {
|
||||
data: { id: "cmt-nosync-1", content: p.content, selection: p.selection, suggestedText: p.suggestedText, type: p.type },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/resync-suggestion-anchor") {
|
||||
resyncCalls++;
|
||||
sendJson(res, 200, { data: {} });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
class TestClient extends DocmostClient {
|
||||
async getCollabTokenWithReauth() {
|
||||
return "collab-token";
|
||||
}
|
||||
async resolvePageId() {
|
||||
return "44444444-4444-4444-4444-444444444444";
|
||||
}
|
||||
async mutatePage(pageId, collabToken, apiUrl, transform) {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello brave world" }] },
|
||||
],
|
||||
};
|
||||
const out = transform(doc);
|
||||
return { doc: out, verify: { ok: true } };
|
||||
}
|
||||
}
|
||||
|
||||
const client = new TestClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.createComment(
|
||||
"44444444-4444-4444-4444-444444444444",
|
||||
"rename",
|
||||
"inline",
|
||||
"brave",
|
||||
undefined,
|
||||
"bold",
|
||||
);
|
||||
|
||||
assert.equal(result.anchored, true);
|
||||
assert.equal(resyncCalls, 0, "matching snapshot must NOT trigger a resync round-trip");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8) #408: a not-found selection error QUOTES the closest block text so the
|
||||
// model can self-correct instead of blind-retrying.
|
||||
|
||||
Reference in New Issue
Block a user