Compare commits
18 Commits
feat/184-a
...
feature/of
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411c05a9d6 | ||
|
|
e8805b39c8 | ||
|
|
67a3663fc5 | ||
|
|
2cf30c7690 | ||
|
|
ca26af9e9d | ||
|
|
3d6f48c3bd | ||
|
|
2f5b520af2 | ||
|
|
655970dd49 | ||
|
|
7ceef2bae6 | ||
|
|
77aa9443e9 | ||
|
|
1ac9a8df98 | ||
|
|
8cfc4c3c40 | ||
|
|
85ad697cd4 | ||
|
|
ccc5e97000 | ||
|
|
df02f2d672 | ||
|
|
7ac7fcba2d | ||
|
|
caeb555039 | ||
|
|
e05495ba4f |
27
.env.example
27
.env.example
@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
|
||||
# Example: https://intranet.example.com,https://portal.example.com
|
||||
IFRAME_ALLOWED_ORIGINS=
|
||||
|
||||
# Comma-separated list of additional origins allowed to call the API via CORS.
|
||||
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
|
||||
# Leave empty for a same-origin (web-only) deployment.
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
|
||||
SWAGGER_ENABLED=false
|
||||
|
||||
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
|
||||
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
|
||||
# Leave empty for Android bundled mode / local development.
|
||||
CAP_SERVER_URL=
|
||||
|
||||
# Enable debug logging in production (default: false)
|
||||
DEBUG_MODE=false
|
||||
|
||||
@@ -170,20 +183,6 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
|
||||
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
||||
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
||||
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
||||
# it, and a client reconnects/live-follows the run.
|
||||
#
|
||||
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
||||
# AbortController that backs it are process-local, so a Stop only aborts a run
|
||||
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
||||
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
||||
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
||||
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -49,3 +49,8 @@ lerna-debug.log*
|
||||
|
||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||
apps/client/public/vad/
|
||||
|
||||
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
|
||||
/ios
|
||||
/android
|
||||
.capacitor
|
||||
|
||||
@@ -259,7 +259,6 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -58,19 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||
contain a standalone footnote definition, which canonicalization would drop.
|
||||
(#228)
|
||||
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
|
||||
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
|
||||
AI-chat turn becomes a first-class, server-side RUN tracked in a new
|
||||
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
|
||||
losing the connection no longer aborts the turn — it keeps executing and
|
||||
persisting server-side, and only an explicit Stop ends it. A client can
|
||||
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
|
||||
(resolve the latest run + its assistant message for a chat) and
|
||||
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
|
||||
enforces one active run per chat, and a startup sweep settles any run left
|
||||
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||
deployment. (#184)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -80,6 +67,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
toggle. Previously the create call defaulted to including sub-pages, silently
|
||||
exposing every child of a freshly shared page. (#216)
|
||||
|
||||
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
|
||||
children, and comments are cached in IndexedDB (TanStack Query persister plus
|
||||
`y-indexeddb` for the page's Yjs document), and a PWA service worker
|
||||
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
|
||||
offline. The two offline stores (the persisted query cache and the Yjs page
|
||||
documents) are cleared on logout AND on sign-in so a previous user's private
|
||||
data does not remain in the browser; the same purge also defensively drops any
|
||||
legacy service-worker `api-get-cache` left by older clients (current builds
|
||||
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
|
||||
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
|
||||
can request the access JWT in the response body (`data.authToken`) in addition
|
||||
to the httpOnly cookie (the web client stays cookie-only); an optional
|
||||
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
|
||||
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
|
||||
`app.enableCors()`). The same-origin web client is unaffected, but any
|
||||
separately-hosted cross-domain client must now be listed in
|
||||
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
|
||||
allowed automatically). Requests with no `Origin` header (server-to-server)
|
||||
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
|
||||
*any* origin (with `credentials:false`), so any previously-working cross-domain
|
||||
REST/browser client is now rejected until its origin is added to
|
||||
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||
"@tabler/icons-react": "3.40.0",
|
||||
"@tanstack/query-async-storage-persister": "5.90.17",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-query-persist-client": "5.90.17",
|
||||
"@tanstack/react-virtual": "3.13.24",
|
||||
"ai": "6.0.207",
|
||||
"alfaaz": "1.1.0",
|
||||
@@ -45,6 +47,7 @@
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
"i18next": "25.10.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"idb-keyval": "6.2.5",
|
||||
"jotai": "2.18.1",
|
||||
"jotai-optics": "0.4.0",
|
||||
"js-cookie": "3.0.7",
|
||||
@@ -95,6 +98,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,18 @@
|
||||
"Move page": "Move page",
|
||||
"Move page to a different space.": "Move page to a different space.",
|
||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
|
||||
"Syncing changes…": "Syncing changes…",
|
||||
"All changes synced": "All changes synced",
|
||||
"Update available": "Update available",
|
||||
"Reload": "Reload",
|
||||
"Make available offline": "Make available offline",
|
||||
"Saving page for offline use...": "Saving page for offline use...",
|
||||
"Page is now available offline": "Page is now available offline",
|
||||
"Failed to make page available offline": "Failed to make page available offline",
|
||||
"You're offline": "You're offline",
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||
"Retry": "Retry",
|
||||
"Table of contents": "Table of contents",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
||||
"Share": "Share",
|
||||
|
||||
@@ -474,6 +474,18 @@
|
||||
"Move page": "Переместить страницу",
|
||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
|
||||
"Syncing changes…": "Синхронизация изменений…",
|
||||
"All changes synced": "Все изменения синхронизированы",
|
||||
"Update available": "Доступно обновление",
|
||||
"Reload": "Перезагрузить",
|
||||
"Make available offline": "Сделать доступным офлайн",
|
||||
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
|
||||
"Page is now available offline": "Страница доступна офлайн",
|
||||
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
|
||||
"You're offline": "Вы офлайн",
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
|
||||
"Retry": "Повторить",
|
||||
"Table of contents": "Оглавление",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||
"Share": "Поделиться",
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Gitmost",
|
||||
"short_name": "Gitmost",
|
||||
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
|
||||
"lang": "en",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0E1117",
|
||||
"theme_color": "#0E1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/favicon-16x16.png",
|
||||
"type": "image/png",
|
||||
"sizes": "16x16"
|
||||
},
|
||||
{
|
||||
"src": "icons/favicon-32x32.png",
|
||||
"type": "image/png",
|
||||
"sizes": "32x32"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180 192x192"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
|
||||
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
|
||||
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
|
||||
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -34,12 +34,9 @@ import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
AI_CHAT_MESSAGES_RQ_KEY,
|
||||
useAiChatMessagesQuery,
|
||||
useAiChatRunQuery,
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { shouldObserveRun } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
@@ -165,61 +162,6 @@ export default function AiChatWindow() {
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
|
||||
// (it is only owner-gated and returns `{ run: null }` when the chat has no
|
||||
// run); but when the feature is off no runs are ever created, so polling it
|
||||
// would always come back empty — we gate it off here to avoid pointless polls.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const autonomousRunsEnabled =
|
||||
workspace?.settings?.ai?.autonomousRuns === true;
|
||||
|
||||
// Whether THIS tab is the one actively streaming the open chat's run locally
|
||||
// (it started the run here and holds the SSE). Reported up from ChatThread. We
|
||||
// are the STREAMER while true and a passive OBSERVER while false — the basis of
|
||||
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
|
||||
// mount effect on every chat switch.
|
||||
const [localStreaming, setLocalStreaming] = useState(false);
|
||||
const onStreamingChange = useCallback((streaming: boolean) => {
|
||||
setLocalStreaming(streaming);
|
||||
}, []);
|
||||
|
||||
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||
// already has the live SSE — polling/merging too would double-render). The
|
||||
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||
const { data: runData } = useAiChatRunQuery(
|
||||
activeChatId ?? undefined,
|
||||
autonomousRunsEnabled && !localStreaming,
|
||||
);
|
||||
const run = runData?.run ?? null;
|
||||
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||
// but only while we are an observer (never when we are the streamer — guards
|
||||
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||
// final persisted output is shown on reopen.
|
||||
const observedRow = shouldObserveRun(run, localStreaming)
|
||||
? (runData?.message ?? null)
|
||||
: null;
|
||||
|
||||
// When the observed run reaches a terminal status, do a final messages refetch
|
||||
// so the persisted final state (token/context badge, export source) is shown,
|
||||
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||
const finalizedRunIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!run || !activeChatId) return;
|
||||
if (run.status === "pending" || run.status === "running") {
|
||||
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||
finalizedRunIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (finalizedRunIdRef.current === run.id) return;
|
||||
finalizedRunIdRef.current = run.id;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||
});
|
||||
}, [run, activeChatId, queryClient]);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -694,12 +636,6 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
// #184: live-follow a still-running run when we reopened the chat as
|
||||
// a passive observer; null when there is nothing to observe or this
|
||||
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||
// while we are the streamer.
|
||||
observedRow={observedRow}
|
||||
onStreamingChange={onStreamingChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ const h = vi.hoisted(() => ({
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
@@ -31,8 +30,6 @@ vi.mock("@ai-sdk/react", () => ({
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||
setMessages: h.state.setMessages,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -143,56 +140,3 @@ describe("ChatThread — send now (#198)", () => {
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// #184 passive-observer merge: when reconnecting to a still-running run, the
|
||||
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
|
||||
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
|
||||
// SSE owns the view, so a stale observedRow must never overwrite it).
|
||||
describe("ChatThread — observer run merge (#184)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.onFinish = null;
|
||||
h.state.setMessages.mockReset();
|
||||
});
|
||||
|
||||
const observedRow = {
|
||||
id: "a-run",
|
||||
role: "assistant",
|
||||
content: "step 1\nstep 2",
|
||||
metadata: {
|
||||
parts: [{ type: "text", text: "step 1\nstep 2" }],
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} as const;
|
||||
|
||||
function renderObserver(status: string) {
|
||||
h.state.status = status;
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={[]}
|
||||
onTurnFinished={vi.fn()}
|
||||
observedRow={observedRow as never}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("merges the polled run message when this tab is a passive observer", () => {
|
||||
renderObserver("ready");
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
// The updater replaces/append the observed assistant row by id.
|
||||
const updater = h.state.setMessages.mock.calls[0][0] as (
|
||||
prev: { id: string; parts: { text: string }[] }[],
|
||||
) => { id: string; parts: { text: string }[] }[];
|
||||
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[1].id).toBe("a-run");
|
||||
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
|
||||
});
|
||||
|
||||
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
|
||||
renderObserver("streaming");
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -87,19 +86,6 @@ interface ChatThreadProps {
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
|
||||
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
|
||||
* the parent polls the reconnect endpoint and feeds the run's incrementally-
|
||||
* persisted assistant message here; we merge it into the live list so new
|
||||
* steps/tool-calls appear as they are persisted. Null when there is nothing to
|
||||
* observe (no run, feature off, or this tab IS the streamer). The merge is
|
||||
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
|
||||
* fight the local stream when we are the streamer. */
|
||||
observedRow?: IAiChatMessageRow | null;
|
||||
/** Report this tab's live streaming status up to the parent, so it can stop
|
||||
* polling the run while WE are the active streamer (the SSE owns the view) and
|
||||
* resume once we go idle. Called from an effect on every transition. */
|
||||
onStreamingChange?: (streaming: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,8 +131,6 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
observedRow,
|
||||
onStreamingChange,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -290,7 +274,7 @@ export default function ChatThread({
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||
const { messages, sendMessage, status, stop, error } = useChat({
|
||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||
// every render mid-stream (see `chatStoreId` above).
|
||||
@@ -394,27 +378,6 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// #184: report our live streaming status up so the parent stops polling the run
|
||||
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
|
||||
// Effect (not render) so it never updates parent state during our own render;
|
||||
// fires on mount with `false`, which also re-syncs the parent after a chat
|
||||
// switch remounts this thread (a fresh mount is idle until the user sends).
|
||||
useEffect(() => {
|
||||
onStreamingChange?.(isStreaming);
|
||||
}, [isStreaming, onStreamingChange]);
|
||||
|
||||
// #184 passive-observer merge: when the parent feeds a polled run message (we
|
||||
// reopened a chat whose run is still going and did NOT start it here), merge it
|
||||
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
|
||||
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
|
||||
// owns the view and a stale observedRow must never overwrite it. `observedRow`
|
||||
// is a stable per-poll object, so this runs once per poll, not per render.
|
||||
useEffect(() => {
|
||||
if (isStreaming || !observedRow) return;
|
||||
const observed = rowToUiMessage(observedRow);
|
||||
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||
}, [observedRow, isStreaming, setMessages]);
|
||||
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
deleteAiChat,
|
||||
deleteAiRole,
|
||||
getAiChatMessages,
|
||||
getAiChatRun,
|
||||
getAiChats,
|
||||
getAiRoleCatalog,
|
||||
getAiRoleCatalogBundle,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import {
|
||||
IAiChat,
|
||||
IAiChatMessageRow,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -36,7 +34,6 @@ import {
|
||||
IAiRoleUpdateFromCatalogResult,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||
|
||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||
@@ -54,18 +51,16 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
"ai-chat-messages",
|
||||
chatId,
|
||||
];
|
||||
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||
|
||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||
export function useAiChatsQuery() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -95,9 +90,7 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
@@ -138,34 +131,6 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||
*
|
||||
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||
* failed fetch can never spin a tight loop.
|
||||
*/
|
||||
export function useAiChatRunQuery(
|
||||
chatId: string | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<IAiChatRunResponse, Error>({
|
||||
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||
queryFn: () => getAiChatRun(chatId as string),
|
||||
enabled: !!chatId && enabled,
|
||||
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameAiChatMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -315,14 +280,11 @@ export function useImportAiRolesFromCatalogMutation() {
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t(
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
{
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
),
|
||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||
// use it); stub it so the module imports cleanly in this hook test.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getAiChatRun: vi.fn(),
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const runningResponse: IAiChatRunResponse = {
|
||||
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||
message: {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "working...",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("useAiChatRunQuery — enable gating", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||
expect(result.current.data?.run?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
// Give any errant fetch a chance to fire, then assert none did.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT fetch when there is no chat id", async () => {
|
||||
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
IAiChatListParams,
|
||||
IAiChatMessageRow,
|
||||
IAiChatMessagesParams,
|
||||
IAiChatRunResponse,
|
||||
IAiRole,
|
||||
IAiRoleCatalog,
|
||||
IAiRoleCatalogBundle,
|
||||
@@ -43,23 +42,6 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||
* persisted lifecycle state and the assistant message it materializes (the
|
||||
* partial output while the run is in-flight, the final output once it finished).
|
||||
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||
* runs, so the endpoint returns `{ run: null }`.
|
||||
*/
|
||||
export async function getAiChatRun(
|
||||
chatId: string,
|
||||
): Promise<IAiChatRunResponse> {
|
||||
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
|
||||
@@ -200,38 +200,6 @@ export interface IAiChatMessageRow {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
|
||||
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
|
||||
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
|
||||
* for display/diagnostics. The DB is the source of truth, so this resolves for an
|
||||
* in-flight run (the browser dropped, the run kept going) and a finished one.
|
||||
*/
|
||||
export interface IAiChatRun {
|
||||
id: string;
|
||||
chatId: string;
|
||||
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
|
||||
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
|
||||
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
|
||||
error?: string | null;
|
||||
stepCount?: number;
|
||||
assistantMessageId?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
|
||||
* assistant message it materializes (the partial/final output, projected from the
|
||||
* persisted rows). Both are `null` when the chat has never had a run.
|
||||
*/
|
||||
export interface IAiChatRunResponse {
|
||||
run: IAiChatRun | null;
|
||||
message: IAiChatMessageRow | null;
|
||||
}
|
||||
|
||||
export interface IAiChatListParams extends QueryParams {}
|
||||
|
||||
export interface IAiChatMessagesParams {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
RUN_POLL_INTERVAL_MS,
|
||||
isRunActive,
|
||||
runPollInterval,
|
||||
shouldObserveRun,
|
||||
mergeObservedMessage,
|
||||
} from "./run-polling.ts";
|
||||
|
||||
function makeRun(status: string): IAiChatRun {
|
||||
return { id: "run-1", chatId: "c1", status };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isRunActive", () => {
|
||||
it("treats pending and running as active", () => {
|
||||
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats terminal / unknown / nullish as not active", () => {
|
||||
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||
expect(isRunActive(null)).toBe(false);
|
||||
expect(isRunActive(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||
it("returns 2000ms while the run is pending/running", () => {
|
||||
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||
});
|
||||
|
||||
it("returns false (stop polling) once the run is terminal", () => {
|
||||
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false (no polling) when there is no run", () => {
|
||||
expect(runPollInterval(null)).toBe(false);
|
||||
expect(runPollInterval(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT observe when there is no run", () => {
|
||||
expect(shouldObserveRun(null, false)).toBe(false);
|
||||
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeObservedMessage", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the observed message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const observed = makeMsg("a1", "first token");
|
||||
const next = mergeObservedMessage(prev, observed);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(observed);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||
* assistant message into the rendered thread. These are the small pure decisions
|
||||
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||
*/
|
||||
|
||||
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||
// so a stale/odd value never polls forever).
|
||||
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||
|
||||
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||
* reaching a terminal status — no separate timeout cap is needed.
|
||||
*/
|
||||
export function runPollInterval(
|
||||
run: IAiChatRun | null | undefined,
|
||||
): number | false {
|
||||
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||
* stopped separately by {@link runPollInterval}.
|
||||
*/
|
||||
export function shouldObserveRun(
|
||||
run: IAiChatRun | null | undefined,
|
||||
localStreaming: boolean,
|
||||
): boolean {
|
||||
return !!run && !localStreaming;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an observed assistant message into the rendered list: replace the message
|
||||
* with the same id in place (the in-progress assistant row is already seeded from
|
||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||
* new array; the input is never mutated.
|
||||
*/
|
||||
export function mergeObservedMessage(
|
||||
messages: UIMessage[],
|
||||
observed: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!observed) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||
if (idx === -1) return [...messages, observed];
|
||||
const next = messages.slice();
|
||||
next[idx] = observed;
|
||||
return next;
|
||||
}
|
||||
154
apps/client/src/features/auth/hooks/use-auth.test.ts
Normal file
154
apps/client/src/features/auth/hooks/use-auth.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// react-i18next: identity t() so the hook renders without an i18n provider.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
// react-router-dom: only useNavigate is used by the hook.
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
// The auth service is the network boundary; stub login/logout per test.
|
||||
const loginMock = vi.fn();
|
||||
const logoutMock = vi.fn();
|
||||
vi.mock("@/features/auth/services/auth-service", () => ({
|
||||
login: (...args: unknown[]) => loginMock(...args),
|
||||
logout: (...args: unknown[]) => logoutMock(...args),
|
||||
forgotPassword: vi.fn(),
|
||||
passwordReset: vi.fn(),
|
||||
setupWorkspace: vi.fn(),
|
||||
verifyUserToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
||||
acceptInvitation: vi.fn(),
|
||||
}));
|
||||
|
||||
// The offline cache purge is the unit under test — assert it is invoked.
|
||||
const clearOfflineCacheMock = vi.fn();
|
||||
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
||||
clearOfflineCache: () => clearOfflineCacheMock(),
|
||||
}));
|
||||
|
||||
// app-route helpers are pure config; provide deterministic values.
|
||||
vi.mock("@/lib/app-route.ts", () => ({
|
||||
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
||||
getPostLoginRedirect: () => "/home",
|
||||
}));
|
||||
|
||||
// Mantine notifications: avoid touching the DOM-bound notification system.
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
import useAuth from "./use-auth";
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
loginMock.mockReset();
|
||||
loginMock.mockResolvedValue(undefined);
|
||||
logoutMock.mockReset();
|
||||
logoutMock.mockResolvedValue(undefined);
|
||||
clearOfflineCacheMock.mockReset();
|
||||
clearOfflineCacheMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("useAuth.handleSignIn", () => {
|
||||
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
||||
const order: string[] = [];
|
||||
clearOfflineCacheMock.mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
});
|
||||
loginMock.mockImplementation(async () => {
|
||||
order.push("login");
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||
});
|
||||
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||
// The purge must run before the new session's login resolves.
|
||||
expect(order).toEqual(["clear", "login"]);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
||||
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||
});
|
||||
|
||||
// Login still proceeds despite the cleanup failure.
|
||||
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAuth.handleLogout", () => {
|
||||
const replaceMock = vi.fn();
|
||||
let originalLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
replaceMock.mockReset();
|
||||
// window.location.replace is the post-logout redirect. jsdom's real `replace`
|
||||
// is a non-configurable method that warns "not implemented", so swap the
|
||||
// whole location object for one whose `replace` we can capture.
|
||||
originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { replace: replaceMock },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
|
||||
const order: string[] = [];
|
||||
clearOfflineCacheMock.mockImplementation(async () => {
|
||||
order.push("clear");
|
||||
});
|
||||
replaceMock.mockImplementation((url: string) => {
|
||||
order.push(`replace:${url}`);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
// Purge must complete before the redirect (which would otherwise interrupt
|
||||
// the async cleanup).
|
||||
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
|
||||
});
|
||||
|
||||
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
|
||||
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
await act(async () => {
|
||||
await result.current.logout();
|
||||
});
|
||||
|
||||
// The thrown purge error is swallowed and the redirect still fires.
|
||||
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,6 +34,20 @@ export default function useAuth() {
|
||||
const handleSignIn = async (data: ILogin) => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
|
||||
// On a shared/kiosk device the prior session may have ended WITHOUT an
|
||||
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
|
||||
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
|
||||
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
|
||||
// cached currentUser/pages/comments on first render (UserProvider serves the
|
||||
// cached user) and A's page bodies would stay readable offline. Best-effort:
|
||||
// never block sign-in on cache cleanup.
|
||||
try {
|
||||
await clearOfflineCache();
|
||||
} catch {
|
||||
// best-effort: never block sign-in on cache cleanup
|
||||
}
|
||||
|
||||
try {
|
||||
await login(data);
|
||||
setIsLoading(false);
|
||||
@@ -123,6 +138,13 @@ export default function useAuth() {
|
||||
const handleLogout = async () => {
|
||||
setCurrentUser(RESET);
|
||||
await logout();
|
||||
// Purge the previous user's offline data while the page is still alive —
|
||||
// window.location.replace below would otherwise interrupt async cleanup.
|
||||
try {
|
||||
await clearOfflineCache();
|
||||
} catch {
|
||||
// best-effort: never block logout on cache cleanup
|
||||
}
|
||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||
};
|
||||
|
||||
|
||||
43
apps/client/src/features/auth/queries/auth-query.test.ts
Normal file
43
apps/client/src/features/auth/queries/auth-query.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AxiosError } from "axios";
|
||||
import { collabTokenRetry } from "./auth-query";
|
||||
|
||||
// Regression for the offline white-screen (#237/#238): offline the collab-token
|
||||
// POST rejects as an axios NETWORK error (isAxiosError === true but
|
||||
// error.response === undefined). The old predicate read `error.response.status`
|
||||
// without a guard and threw an uncaught TypeError inside the React Query retryer
|
||||
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
|
||||
describe("collabTokenRetry", () => {
|
||||
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
|
||||
// An axios error with no `response` is exactly the offline/network-failure shape.
|
||||
const networkError = new AxiosError("Network Error");
|
||||
expect(networkError.response).toBeUndefined();
|
||||
|
||||
let result: boolean | number = false;
|
||||
expect(() => {
|
||||
result = collabTokenRetry(0, networkError);
|
||||
}).not.toThrow();
|
||||
// Network failures stay retryable (truthy), matching the original intent.
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false (no retry) for a real 404 response", () => {
|
||||
const notFound = new AxiosError("Not Found");
|
||||
notFound.response = { status: 404 } as AxiosError["response"];
|
||||
expect(collabTokenRetry(0, notFound)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries for a non-404 response (e.g. 500)", () => {
|
||||
const serverError = new AxiosError("Server Error");
|
||||
serverError.response = { status: 500 } as AxiosError["response"];
|
||||
expect(collabTokenRetry(0, serverError)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw and retries for a non-axios error", () => {
|
||||
let result: boolean | number = false;
|
||||
expect(() => {
|
||||
result = collabTokenRetry(0, new Error("boom"));
|
||||
}).not.toThrow();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
/**
|
||||
* Retry predicate for the collab-token query.
|
||||
*
|
||||
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
|
||||
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
|
||||
* `error.response.status` without a guard threw an uncaught TypeError inside the
|
||||
* React Query retryer BEFORE React mounted, white-screening the whole app on an
|
||||
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
|
||||
* keeps the predicate total: a network error (no response) is retryable, a real
|
||||
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
|
||||
*/
|
||||
export function collabTokenRetry(
|
||||
_failureCount: number,
|
||||
error: Error,
|
||||
): boolean {
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useVerifyUserTokenQuery(
|
||||
verify: IVerifyUserToken,
|
||||
): UseQueryResult<any, Error> {
|
||||
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||
//refetchIntervalInBackground: true,
|
||||
refetchOnMount: true,
|
||||
//@ts-ignore
|
||||
retry: (failureCount, error) => {
|
||||
if (isAxiosError(error) && error.response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
retry: collabTokenRetry,
|
||||
retryDelay: (retryAttempt) => {
|
||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||
|
||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||
|
||||
@@ -60,6 +61,9 @@ export function useCreateCommentMutation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IComment, Error, Partial<IComment>>({
|
||||
// Stable key so a paused comment-create restored from IndexedDB after an
|
||||
// offline reload finds its default mutationFn and is replayed on reconnect.
|
||||
mutationKey: offlineMutationKeys.createComment,
|
||||
mutationFn: (data) => createComment(data),
|
||||
onSuccess: (newComment) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
|
||||
@@ -10,6 +10,12 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
|
||||
export const isLocalSyncedAtom = atom<boolean>(false);
|
||||
|
||||
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
|
||||
export const isRemoteSyncedAtom = atom<boolean>(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type * as Y from "yjs";
|
||||
|
||||
// Shared collaboration providers lifted above the title/body editors so that
|
||||
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
|
||||
// in a dedicated 'title' fragment of the same doc as the body.
|
||||
export interface EditorProvidersContextValue {
|
||||
ydoc: Y.Doc;
|
||||
remote: HocuspocusProvider;
|
||||
providersReady: boolean;
|
||||
}
|
||||
|
||||
export const EditorProvidersContext =
|
||||
createContext<EditorProvidersContextValue | null>(null);
|
||||
|
||||
// Returns the shared providers, or null when rendered outside of a provider.
|
||||
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
|
||||
export function useEditorProviders(): EditorProvidersContextValue | null {
|
||||
return useContext(EditorProvidersContext);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
|
||||
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
@@ -80,16 +82,24 @@ export function FullEditor({
|
||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||
// that enables the chat agent); the server enforces it too (#199).
|
||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
// `user` can momentarily be null during logout teardown (the currentUser atom
|
||||
// is reset before this subtree unmounts). Optional-chain every access so the
|
||||
// teardown render does not throw "Cannot read properties of null (reading
|
||||
// 'settings')".
|
||||
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
|
||||
const editorToolbarEnabled =
|
||||
user.settings?.preferences?.editorToolbar ?? false;
|
||||
user?.settings?.preferences?.editorToolbar ?? false;
|
||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||
currentPageEditModeAtom,
|
||||
);
|
||||
const userPageEditMode =
|
||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||
|
||||
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||
// editors (title lives in the 'title' fragment of the same doc).
|
||||
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
|
||||
|
||||
// Apply the user's saved preference only once on initial load, not on every
|
||||
// page navigation — so the mode sticks across navigations within a session.
|
||||
useEffect(() => {
|
||||
@@ -110,28 +120,32 @@ export function FullEditor({
|
||||
)}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
title={title}
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<EditorProvidersContext.Provider
|
||||
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
|
||||
>
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
title={title}
|
||||
spaceSlug={spaceSlug}
|
||||
editable={editable}
|
||||
/>
|
||||
<PageByline
|
||||
pageId={pageId}
|
||||
creator={creator}
|
||||
contributors={contributors}
|
||||
editable={editable}
|
||||
isEditMode={isEditMode}
|
||||
isDictationEnabled={isDictationEnabled}
|
||||
isTitleGenEnabled={isTitleGenEnabled}
|
||||
/>
|
||||
<MemoizedPageEditor
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</EditorProvidersContext.Provider>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/client/src/features/editor/hooks/collab-token.test.ts
Normal file
48
apps/client/src/features/editor/hooks/collab-token.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// jwt-decode is mocked so we can drive the four token states deterministically
|
||||
// (decode success with a chosen exp, or a thrown decode error).
|
||||
const decodeMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("jwt-decode", () => ({
|
||||
jwtDecode: decodeMock,
|
||||
}));
|
||||
|
||||
import { collabTokenNeedsRefresh } from "./collab-token";
|
||||
|
||||
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
|
||||
|
||||
beforeEach(() => {
|
||||
decodeMock.mockReset();
|
||||
});
|
||||
|
||||
describe("collabTokenNeedsRefresh", () => {
|
||||
it("returns true when there is no token (fetch a fresh one)", () => {
|
||||
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
|
||||
// jwtDecode must not even be called for a missing token.
|
||||
expect(decodeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true when the token is malformed (jwtDecode throws)", () => {
|
||||
decodeMock.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
|
||||
// exp is in the future relative to NOW.
|
||||
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
|
||||
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a valid but expired token (refresh + reconnect)", () => {
|
||||
// exp is in the past relative to NOW.
|
||||
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
|
||||
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats exp exactly equal to now as expired (>= boundary)", () => {
|
||||
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
|
||||
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
|
||||
});
|
||||
});
|
||||
26
apps/client/src/features/editor/hooks/collab-token.ts
Normal file
26
apps/client/src/features/editor/hooks/collab-token.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
/**
|
||||
* Decide whether a collab token must be refreshed before reconnecting after an
|
||||
* onAuthenticationFailed event. Pure and side-effect free so the four token
|
||||
* states can be unit-tested directly:
|
||||
* - no token -> true (fetch a fresh one and reconnect)
|
||||
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
|
||||
* - valid, not expired -> false (token is still good; do NOT reconnect)
|
||||
* - valid, expired -> true (refresh + reconnect)
|
||||
*
|
||||
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
|
||||
*/
|
||||
export function collabTokenNeedsRefresh(
|
||||
token: string | undefined,
|
||||
nowMs: number = Date.now(),
|
||||
): boolean {
|
||||
if (!token) return true;
|
||||
try {
|
||||
const payload = jwtDecode<{ exp: number }>(token);
|
||||
return nowMs / 1000 >= payload.exp;
|
||||
} catch {
|
||||
// malformed/undecodable token -> refresh
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
||||
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
||||
"Generated Title",
|
||||
);
|
||||
// The title editor is bound to the Yjs `title` fragment; the server REST
|
||||
// update reseeds that fragment and the reseed reaches the bound editor on
|
||||
// its own. Writing here too would double/garble the title, so the hook must
|
||||
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
||||
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
||||
// ...the hook never writes the editor regardless of navigation...
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
||||
const store = createStore();
|
||||
const titleEditor = makeTitleEditor();
|
||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||
store.set(titleEditorAtom as never, titleEditor);
|
||||
|
||||
// Resolve generation under our control so we can mark the live title editor
|
||||
// as focused before the post-generation write runs.
|
||||
let resolveTitle!: (t: string) => void;
|
||||
generatePageTitleMock.mockReturnValue(
|
||||
new Promise<string>((res) => {
|
||||
resolveTitle = res;
|
||||
}),
|
||||
);
|
||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
||||
const { result } = setup("pageA", store);
|
||||
|
||||
let pending!: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.mutateAsync();
|
||||
});
|
||||
|
||||
// The user clicked into the title field while the model ran — overwriting it
|
||||
// now would clobber what they are actively typing.
|
||||
act(() => {
|
||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveTitle("Generated Title");
|
||||
await pending;
|
||||
});
|
||||
|
||||
// The DB write still persists the value...
|
||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
||||
pageId: "pageA",
|
||||
title: "Generated Title",
|
||||
});
|
||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||
// ...but the visible field is left alone while it is focused.
|
||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
// The change is still broadcast to other clients.
|
||||
expect(localEmitMock).toHaveBeenCalled();
|
||||
// ...and the change is still broadcast to other clients.
|
||||
expect(emitMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useRef } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
|
||||
export function useGeneratePageTitle(pageId: string) {
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const titleEditor = useAtomValue(titleEditorAtom);
|
||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
|
||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
||||
// its closure captures the editors from the render that started it. Keep a live
|
||||
// reference so the post-generation write targets whatever page is on screen
|
||||
// *now*, not the page the generation was started from.
|
||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
||||
editorsRef.current = { pageEditor, titleEditor };
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
|
||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||
updatePageData(page); // refresh the react-query cache
|
||||
|
||||
// Reflect the new title in the field immediately. The button lives in the
|
||||
// byline, so the title editor is not focused — setContent is safe and stays
|
||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
||||
//
|
||||
// Guard against navigation during generation: if the user switched pages
|
||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
||||
// page, so writing here would drop page A's title into page B's visible
|
||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
||||
// pageId` guard — bail the visible write unless that live editor still
|
||||
// belongs to the page this title was generated for. The DB write above is
|
||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
||||
// still propagates page A's change to other clients.
|
||||
const livePageEditor = editorsRef.current.pageEditor;
|
||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
||||
?.pageId;
|
||||
const stillOnPage = livePageId === pageId;
|
||||
if (
|
||||
stillOnPage &&
|
||||
liveTitleEditor &&
|
||||
!liveTitleEditor.isDestroyed &&
|
||||
!liveTitleEditor.isFocused
|
||||
) {
|
||||
liveTitleEditor.commands.setContent(page.title);
|
||||
}
|
||||
// Do NOT write the title into the editor here. The title editor is bound to
|
||||
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
|
||||
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
|
||||
// a full clear+replace) and the reseed reaches the bound title editor on
|
||||
// its own as a remote provider update. The old REST-era setContent here
|
||||
// would race that reseed and double/garble the title (the "Yjs duplication
|
||||
// trap"), so it is intentionally omitted. The DB write above is keyed by
|
||||
// the captured `pageId`, so it stays correct even if the user navigated
|
||||
// away during generation.
|
||||
|
||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||
const event: UpdateEvent = {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||
import {
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
|
||||
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||
import { pageKeys } from "@/features/page/queries/page-query";
|
||||
|
||||
export interface PageCollabProviders {
|
||||
ydoc: Y.Doc | null;
|
||||
remote: HocuspocusProvider | null;
|
||||
socket: HocuspocusProviderWebsocket | null;
|
||||
providersReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the full collaboration provider lifecycle for a page so that the title
|
||||
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
|
||||
* is relocated verbatim from page-editor.tsx: it creates the providers once per
|
||||
* pageId, connects/disconnects on idle/visibility, attaches each render,
|
||||
* destroys on unmount, refreshes the collab token on auth failure, and applies
|
||||
* the onStateless 'page.updated' cache update.
|
||||
*/
|
||||
export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
|
||||
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
// The provider-creating effect runs only once per pageId, so any token read
|
||||
// inside its handlers would be captured STALE (the old token at first render).
|
||||
// Mirror the latest token into a ref the auth-failure handler can read live.
|
||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
collabTokenRef.current = collabQuery?.token;
|
||||
}, [collabQuery?.token]);
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
ydoc: Y.Doc;
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = pageYdocName(pageId);
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
url: collaborationURL,
|
||||
});
|
||||
const onLocalSyncedHandler = () => {
|
||||
setIsLocalSyncedAtom(true);
|
||||
};
|
||||
const onStatusHandler = (event: onStatusParameters) => {
|
||||
setYjsConnectionStatus(event.status);
|
||||
};
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSyncedAtom(event.state);
|
||||
};
|
||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||
try {
|
||||
const message = JSON.parse(payload);
|
||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(
|
||||
pageKeys.detail(slugId),
|
||||
);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(pageKeys.detail(slugId), {
|
||||
...pageData,
|
||||
updatedAt: message.updatedAt,
|
||||
...(message.lastUpdatedBy && {
|
||||
lastUpdatedBy: message.lastUpdatedBy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore unrelated stateless messages
|
||||
}
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
// Read the token from the ref, not the closed-over `collabQuery`: this
|
||||
// handler is created once and would otherwise decode a stale token after
|
||||
// a refetch. A missing/malformed token must NOT crash the handler —
|
||||
// jwtDecode(undefined) throws — so treat any decode failure as "needs
|
||||
// refresh" and proceed to refetch + reconnect instead of getting stuck.
|
||||
if (!collabTokenNeedsRefresh(collabTokenRef.current)) return;
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
onStateless: onStatelessHandler,
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { ydoc, socket, local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
// Reset shared sync state on page change/unmount.
|
||||
setIsLocalSyncedAtom(false);
|
||||
setIsRemoteSyncedAtom(false);
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
) {
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
|
||||
return {
|
||||
ydoc: providersRef.current?.ydoc ?? null,
|
||||
remote: providersRef.current?.remote ?? null,
|
||||
socket: providersRef.current?.socket ?? null,
|
||||
providersReady,
|
||||
};
|
||||
}
|
||||
@@ -6,16 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
HocuspocusProvider,
|
||||
onStatusParameters,
|
||||
WebSocketStatus,
|
||||
HocuspocusProviderWebsocket,
|
||||
onSyncedParameters,
|
||||
onStatelessParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
@@ -28,13 +19,15 @@ import {
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
@@ -58,10 +51,8 @@ import {
|
||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
@@ -72,9 +63,7 @@ import {
|
||||
GitmostInsertRecordingResult,
|
||||
gitmostInsertRecordingIntoEditor,
|
||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||
@@ -103,7 +92,6 @@ export default function PageEditor({
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
@@ -117,22 +105,10 @@ export default function PageEditor({
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
yjsConnectionStatusAtom,
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
// Always holds the latest collab token. The provider effect below runs once
|
||||
// per pageId, so a handler created inside it would otherwise close over a
|
||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
collabTokenRef.current = collabQuery?.token;
|
||||
}, [collabQuery?.token]);
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
@@ -141,141 +117,27 @@ export default function PageEditor({
|
||||
[isComponentMounted],
|
||||
);
|
||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||
// Providers only created once per pageId
|
||||
const providersRef = useRef<{
|
||||
local: IndexeddbPersistence;
|
||||
remote: HocuspocusProvider;
|
||||
socket: HocuspocusProviderWebsocket;
|
||||
} | null>(null);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providersRef.current) {
|
||||
const documentName = `page.${pageId}`;
|
||||
const ydoc = new Y.Doc();
|
||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||
const socket = new HocuspocusProviderWebsocket({
|
||||
url: collaborationURL,
|
||||
});
|
||||
const onLocalSyncedHandler = () => {
|
||||
setIsLocalSynced(true);
|
||||
};
|
||||
const onStatusHandler = (event: onStatusParameters) => {
|
||||
setYjsConnectionStatus(event.status);
|
||||
};
|
||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||
setIsRemoteSynced(event.state);
|
||||
};
|
||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||
try {
|
||||
const message = JSON.parse(payload);
|
||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
||||
if (pageData) {
|
||||
queryClient.setQueryData(["pages", slugId], {
|
||||
...pageData,
|
||||
updatedAt: message.updatedAt,
|
||||
...(message.lastUpdatedBy && {
|
||||
lastUpdatedBy: message.lastUpdatedBy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore unrelated stateless messages
|
||||
}
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
||||
// may be stale). Guard the decode: a missing or unparseable token must
|
||||
// not throw "Invalid token specified" and should trigger a refresh so
|
||||
// the editor reconnects even when the initial token fetch failed.
|
||||
const token = collabTokenRef.current;
|
||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
||||
if (token) {
|
||||
try {
|
||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
||||
} catch {
|
||||
needsRefresh = true;
|
||||
}
|
||||
}
|
||||
if (!needsRefresh) return;
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token: collabQuery?.token,
|
||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||
onStatus: onStatusHandler,
|
||||
onSynced: onSyncedHandler,
|
||||
onStateless: onStatelessHandler,
|
||||
});
|
||||
|
||||
local.on("synced", onLocalSyncedHandler);
|
||||
providersRef.current = { socket, local, remote };
|
||||
setProvidersReady(true);
|
||||
} else {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
// Only destroy on final unmount
|
||||
return () => {
|
||||
providersRef.current?.socket.destroy();
|
||||
providersRef.current?.remote.destroy();
|
||||
providersRef.current?.local.destroy();
|
||||
providersRef.current = null;
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
// Only connect/disconnect on tab/idle, not destroy
|
||||
useEffect(() => {
|
||||
if (!providersReady || !providersRef.current) return;
|
||||
const socket = providersRef.current.socket;
|
||||
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Connected
|
||||
) {
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||
) {
|
||||
resetIdle();
|
||||
socket.connect();
|
||||
}
|
||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||
|
||||
// Attach here, to make sure the connection gets properly established
|
||||
providersRef.current?.remote.attach();
|
||||
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
|
||||
// the context (defensive) — in practice full-editor always provides it.
|
||||
const editorProviders = useEditorProviders();
|
||||
const remote = editorProviders?.remote ?? null;
|
||||
const providersReady = editorProviders?.providersReady ?? false;
|
||||
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
||||
if (!providersReady || !remote || !currentUser?.user) {
|
||||
return mainExtensions;
|
||||
}
|
||||
|
||||
const remoteProvider = providersRef.current.remote;
|
||||
|
||||
return [
|
||||
...mainExtensions,
|
||||
...collabExtensions(remoteProvider, currentUser?.user),
|
||||
...collabExtensions(remote, currentUser?.user),
|
||||
];
|
||||
}, [providersReady, currentUser?.user]);
|
||||
}, [providersReady, remote, currentUser?.user]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
@@ -550,7 +412,7 @@ export default function PageEditor({
|
||||
{editor &&
|
||||
!editorIsEditable &&
|
||||
(editable || canComment) &&
|
||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
||||
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||
{showCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} />
|
||||
)}
|
||||
|
||||
14
apps/client/src/features/editor/page-ydoc-name.ts
Normal file
14
apps/client/src/features/editor/page-ydoc-name.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Single source of truth for the IndexedDB / Hocuspocus document name of a
|
||||
* page's collaborative Yjs doc.
|
||||
*
|
||||
* The `page.<id>` convention is shared knowledge across three call sites: the
|
||||
* live editor providers (`use-page-collab-providers`), the offline warm path
|
||||
* (`make-offline`), and the offline purge (`clear-offline-cache`, which matches
|
||||
* the databases to delete by this prefix). Centralizing it here stops those
|
||||
* sites from silently drifting apart.
|
||||
*/
|
||||
export const PAGE_YDOC_NAME_PREFIX = "page.";
|
||||
|
||||
export const pageYdocName = (pageId: string): string =>
|
||||
`${PAGE_YDOC_NAME_PREFIX}${pageId}`;
|
||||
33
apps/client/src/features/editor/title-collab.test.ts
Normal file
33
apps/client/src/features/editor/title-collab.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// isChangeOrigin is mocked so we can simulate local vs remote/collab-origin
|
||||
// transactions without constructing a real ProseMirror/Yjs transaction.
|
||||
const isChangeOriginMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@tiptap/extension-collaboration", () => ({
|
||||
isChangeOrigin: isChangeOriginMock,
|
||||
}));
|
||||
|
||||
import { shouldPropagateTitleChange } from "./title-collab";
|
||||
|
||||
beforeEach(() => {
|
||||
isChangeOriginMock.mockReset();
|
||||
});
|
||||
|
||||
describe("shouldPropagateTitleChange", () => {
|
||||
it("propagates a genuine local edit (isChangeOrigin false)", () => {
|
||||
isChangeOriginMock.mockReturnValue(false);
|
||||
expect(shouldPropagateTitleChange({ local: true })).toBe(true);
|
||||
expect(isChangeOriginMock).toHaveBeenCalledWith({ local: true });
|
||||
});
|
||||
|
||||
it("skips a remote/collab-origin update (isChangeOrigin true)", () => {
|
||||
isChangeOriginMock.mockReturnValue(true);
|
||||
expect(shouldPropagateTitleChange({ remote: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("propagates when there is no transaction (treated as local)", () => {
|
||||
expect(shouldPropagateTitleChange(undefined)).toBe(true);
|
||||
// isChangeOrigin must not be called for a missing transaction.
|
||||
expect(isChangeOriginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
19
apps/client/src/features/editor/title-collab.ts
Normal file
19
apps/client/src/features/editor/title-collab.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||
|
||||
/**
|
||||
* Whether a TitleEditor `onUpdate` should drive URL + tree propagation.
|
||||
*
|
||||
* Only genuine LOCAL edits propagate. Remote/collab-origin Yjs updates
|
||||
* (detected via `isChangeOrigin`) are skipped so a remote title change is not
|
||||
* re-broadcast back, which would create a feedback loop. A missing transaction
|
||||
* is treated as a local edit (propagate).
|
||||
*
|
||||
* Extracted as a pure helper so the skip decision is unit-testable without
|
||||
* mounting the full collaborative editor.
|
||||
*/
|
||||
export function shouldPropagateTitleChange(transaction: unknown): boolean {
|
||||
return !(
|
||||
transaction &&
|
||||
isChangeOrigin(transaction as Parameters<typeof isChangeOrigin>[0])
|
||||
);
|
||||
}
|
||||
87
apps/client/src/features/editor/title-editor.test.tsx
Normal file
87
apps/client/src/features/editor/title-editor.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
// Drive the fallback-vs-collaborative switch (titleReady = providersReady &&
|
||||
// !!ydoc) by controlling what the editor-providers context returns.
|
||||
const editorProvidersValue: { ydoc: unknown; providersReady: boolean } = {
|
||||
ydoc: null,
|
||||
providersReady: false,
|
||||
};
|
||||
vi.mock("@/features/editor/contexts/editor-providers-context", () => ({
|
||||
useEditorProviders: () => editorProvidersValue,
|
||||
}));
|
||||
|
||||
// Mock the tiptap React bindings so the test does not mount a real editor:
|
||||
// useEditor returns a minimal stub and EditorContent renders a marker.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditor: () => ({
|
||||
isInitialized: true,
|
||||
commands: { focus: vi.fn() },
|
||||
setEditable: vi.fn(),
|
||||
getText: () => "",
|
||||
}),
|
||||
EditorContent: () => <div data-testid="collab-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||
useQueryEmit: () => vi.fn(),
|
||||
}));
|
||||
|
||||
// page-query transitively imports @/main.tsx; mock it to a pure stub.
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
updatePageData: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { getQueryData: vi.fn(), setQueryData: vi.fn() },
|
||||
}));
|
||||
|
||||
import { TitleEditor } from "./title-editor";
|
||||
|
||||
const baseProps = {
|
||||
pageId: "p1",
|
||||
slugId: "slug-1",
|
||||
title: "My Page Title",
|
||||
spaceSlug: "space",
|
||||
editable: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
navigateMock.mockReset();
|
||||
editorProvidersValue.ydoc = null;
|
||||
editorProvidersValue.providersReady = false;
|
||||
});
|
||||
|
||||
describe("TitleEditor fallback vs collaborative switch", () => {
|
||||
it("renders a static <h1> with the title before the shared doc is ready", () => {
|
||||
editorProvidersValue.ydoc = null;
|
||||
editorProvidersValue.providersReady = false;
|
||||
|
||||
render(<TitleEditor {...baseProps} />);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.textContent).toBe("My Page Title");
|
||||
// The collaborative editor must NOT mount until the doc is ready.
|
||||
expect(screen.queryByTestId("collab-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the collaborative editor once the shared doc is ready", () => {
|
||||
editorProvidersValue.ydoc = {}; // truthy shared doc
|
||||
editorProvidersValue.providersReady = true;
|
||||
|
||||
render(<TitleEditor {...baseProps} />);
|
||||
|
||||
expect(screen.getByTestId("collab-editor")).toBeDefined();
|
||||
// The static fallback <h1> is gone — Yjs is the single source of truth and
|
||||
// the prop is never seeded into the collaborative editor.
|
||||
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@/features/editor/styles/index.css";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
pageEditorAtom,
|
||||
titleEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
updatePageData,
|
||||
useUpdateTitlePageMutation,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
|
||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
||||
import { History } from "@tiptap/extension-history";
|
||||
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -28,6 +25,9 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -45,65 +45,82 @@ export function TitleEditor({
|
||||
editable,
|
||||
}: TitleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
||||
useUpdateTitlePageMutation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||
const emit = useQueryEmit();
|
||||
const navigate = useNavigate();
|
||||
const [activePageId, setActivePageId] = useState(pageId);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
|
||||
const titleEditor = useEditor({
|
||||
extensions: [
|
||||
Document.extend({
|
||||
content: "heading",
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
History.configure({
|
||||
depth: 20,
|
||||
}),
|
||||
EmojiCommand,
|
||||
],
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setTitleEditor(editor);
|
||||
setActivePageId(pageId);
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
debounceUpdate();
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": t("Page title"),
|
||||
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
|
||||
// the body). Yjs is the source of truth for the title content.
|
||||
const editorProviders = useEditorProviders();
|
||||
const ydoc = editorProviders?.ydoc ?? null;
|
||||
const providersReady = editorProviders?.providersReady ?? false;
|
||||
|
||||
// Until the shared doc is ready, the collaborative editor binds nothing and
|
||||
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
|
||||
// a non-editable static <h1> with the `title` prop in the meantime. The prop
|
||||
// is NEVER fed into the collaborative editor (Yjs stays the single source of
|
||||
// truth — seeding it would duplicate the title).
|
||||
const titleReady = providersReady && !!ydoc;
|
||||
|
||||
const titleEditor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
Document.extend({
|
||||
content: "heading",
|
||||
}),
|
||||
Heading.configure({
|
||||
levels: [1],
|
||||
}),
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t("Untitled"),
|
||||
showOnlyWhenEditable: false,
|
||||
}),
|
||||
// Bind the title to the dedicated 'title' fragment of the shared doc.
|
||||
// Collaboration also manages undo/redo, so the History extension is
|
||||
// intentionally omitted (it would conflict with Yjs). When the doc is
|
||||
// not ready yet the editor renders empty until the doc arrives.
|
||||
...(ydoc
|
||||
? [Collaboration.configure({ document: ydoc, field: "title" })]
|
||||
: []),
|
||||
EmojiCommand,
|
||||
],
|
||||
onCreate({ editor }) {
|
||||
if (editor) {
|
||||
// @ts-ignore
|
||||
setTitleEditor(editor);
|
||||
}
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
onUpdate({ editor, transaction }) {
|
||||
// Drive URL + tree propagation only on genuine local edits; skip
|
||||
// remote/collab-origin Yjs updates to avoid feedback loops.
|
||||
if (!shouldPropagateTitleChange(transaction)) return;
|
||||
debouncedPropagateTitle(editor.getText());
|
||||
},
|
||||
editable: editable,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
"aria-label": t("Page title"),
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||
searchSpotlight.open();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
[pageId, ydoc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const anchorId = window.location.hash
|
||||
@@ -113,59 +130,45 @@ export function TitleEditor({
|
||||
navigate(pageSlug, { replace: true });
|
||||
}, [title]);
|
||||
|
||||
const saveTitle = useCallback(() => {
|
||||
if (!titleEditor || activePageId !== pageId) return;
|
||||
|
||||
if (
|
||||
titleEditor.getText() === title ||
|
||||
(titleEditor.getText() === "" && title === null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateTitlePageMutationAsync({
|
||||
pageId: pageId,
|
||||
title: titleEditor.getText(),
|
||||
}).then((page) => {
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: page.title,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
|
||||
if (page.title !== titleEditor.getText()) return;
|
||||
|
||||
updatePageData(page);
|
||||
|
||||
localEmitter.emit("message", event);
|
||||
emit(event);
|
||||
// On a local title change: update the URL slug and propagate the change to
|
||||
// the live tree/breadcrumbs for online users. No REST round-trip — the title
|
||||
// itself is persisted through Yjs. Offline this simply no-ops the socket
|
||||
// emit and the title syncs on reconnect.
|
||||
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
|
||||
const anchorId = window.location.hash
|
||||
? window.location.hash.substring(1)
|
||||
: undefined;
|
||||
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
|
||||
replace: true,
|
||||
});
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
||||
const page =
|
||||
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
|
||||
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
if (!page) return;
|
||||
|
||||
useEffect(() => {
|
||||
// Do not overwrite the title while the user is actively editing it. The
|
||||
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
|
||||
// carry a title that lags behind what the user has just typed; resetting
|
||||
// content from it here would drop in-progress characters and jump the
|
||||
// cursor. Apply external title changes only when the field is not focused.
|
||||
if (
|
||||
titleEditor &&
|
||||
!titleEditor.isDestroyed &&
|
||||
!titleEditor.isFocused &&
|
||||
title !== titleEditor.getText()
|
||||
) {
|
||||
titleEditor.commands.setContent(title);
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
const updatedPage: IPage = { ...page, title: titleText };
|
||||
|
||||
const event: UpdateEvent = {
|
||||
operation: "updateOne",
|
||||
spaceId: page.spaceId,
|
||||
entity: ["pages"],
|
||||
id: page.id,
|
||||
payload: {
|
||||
title: titleText,
|
||||
slugId: page.slugId,
|
||||
parentPageId: page.parentPageId,
|
||||
icon: page.icon,
|
||||
},
|
||||
};
|
||||
|
||||
updatePageData(updatedPage);
|
||||
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
||||
// refresh is handled server-side: the collab process extracts the renamed
|
||||
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
|
||||
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
|
||||
localEmitter.emit("message", event);
|
||||
}, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
@@ -175,13 +178,6 @@ export function TitleEditor({
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// force-save title on navigation
|
||||
saveTitle();
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleEditor) return;
|
||||
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
|
||||
@@ -248,16 +244,22 @@ export function TitleEditor({
|
||||
|
||||
return (
|
||||
<div className="page-title">
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
{titleReady ? (
|
||||
<EditorContent
|
||||
editor={titleEditor}
|
||||
onKeyDown={(event) => {
|
||||
// First handle the search hotkey
|
||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
// Then handle other key events
|
||||
handleTitleKeyDown(event);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// Static, non-editable fallback so the title is visible before Yjs
|
||||
// hydrates the 'title' fragment. Not wired into the collaborative editor.
|
||||
<h1>{title}</h1>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -36,21 +38,39 @@ function CreateNoteButton({
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
|
||||
const createNote = async (space: ISpace) => {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const variables = {
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any;
|
||||
|
||||
if (!onlineManager.isOnline()) {
|
||||
// Offline: the create is PAUSED and queued — its promise will not resolve
|
||||
// until we are back online, so awaiting it here would spin the button
|
||||
// forever. Fire it without awaiting (it persists and replays on reconnect)
|
||||
// and tell the user it was saved offline instead of leaving a dead spinner.
|
||||
createPageMutation.mutate(variables);
|
||||
notifications.show({
|
||||
color: "blue",
|
||||
message: t("You're offline. This note will be created once you reconnect."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||
// signature.
|
||||
const createdPage = await createPageMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
...(temporary ? { temporary: true } : {}),
|
||||
} as any);
|
||||
const createdPage = await createPageMutation.mutateAsync(variables);
|
||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||
} catch {
|
||||
// useCreatePageMutation already surfaces a red notification on error.
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createPageMutation.isPending;
|
||||
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
|
||||
// being paused — otherwise the button would spin forever after an offline
|
||||
// create. The offline path above gives its own "saved offline" feedback.
|
||||
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
|
||||
|
||||
// Exactly one writable space → create directly, no picker needed.
|
||||
if (writableSpaces.length === 1) {
|
||||
|
||||
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
107
apps/client/src/features/offline/clear-offline-cache.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// vi.mock factories are hoisted above imports, so the spies they reference must
|
||||
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
|
||||
const h = vi.hoisted(() => ({
|
||||
clear: vi.fn(),
|
||||
del: vi.fn(),
|
||||
}));
|
||||
|
||||
// The module under test imports the app entry at load time — it must be mocked.
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { clear: h.clear },
|
||||
}));
|
||||
vi.mock("idb-keyval", () => ({
|
||||
del: h.del,
|
||||
}));
|
||||
|
||||
import { clearOfflineCache } from "./clear-offline-cache";
|
||||
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||
|
||||
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
|
||||
// globals are stubbed per-test. We restore them afterwards.
|
||||
const originalIndexedDB = (globalThis as any).indexedDB;
|
||||
const originalCaches = (globalThis as any).caches;
|
||||
|
||||
beforeEach(() => {
|
||||
h.clear.mockClear();
|
||||
h.del.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).indexedDB = originalIndexedDB;
|
||||
(globalThis as any).caches = originalCaches;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("clearOfflineCache", () => {
|
||||
it("resolves without throwing when the browser globals are absent", async () => {
|
||||
(globalThis as any).indexedDB = undefined;
|
||||
delete (globalThis as any).caches;
|
||||
|
||||
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||
|
||||
// The two store-agnostic steps still run.
|
||||
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
|
||||
});
|
||||
|
||||
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
|
||||
const deleteDatabase = vi.fn((_name: string) => {
|
||||
const request: any = {};
|
||||
// Resolve the deletion on the next microtask, like a real IDBRequest.
|
||||
queueMicrotask(() => request.onsuccess && request.onsuccess());
|
||||
return request;
|
||||
});
|
||||
(globalThis as any).indexedDB = {
|
||||
databases: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ name: "page.aaa" },
|
||||
{ name: "page.bbb" },
|
||||
{ name: "keyval-store" },
|
||||
{ name: undefined },
|
||||
]),
|
||||
deleteDatabase,
|
||||
};
|
||||
|
||||
const cacheDelete = vi.fn().mockResolvedValue(true);
|
||||
(globalThis as any).caches = {
|
||||
keys: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
"workbox-runtime-https://app/api-get-cache",
|
||||
"other-cache",
|
||||
]),
|
||||
delete: cacheDelete,
|
||||
};
|
||||
|
||||
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||
|
||||
// Only the two page.* databases are deleted.
|
||||
expect(deleteDatabase).toHaveBeenCalledTimes(2);
|
||||
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
|
||||
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
|
||||
|
||||
// Only the api-get-cache entry is deleted.
|
||||
expect(cacheDelete).toHaveBeenCalledTimes(1);
|
||||
expect(cacheDelete).toHaveBeenCalledWith(
|
||||
"workbox-runtime-https://app/api-get-cache",
|
||||
);
|
||||
});
|
||||
|
||||
it("never throws even if a step rejects (best-effort)", async () => {
|
||||
h.del.mockRejectedValueOnce(new Error("idb boom"));
|
||||
(globalThis as any).indexedDB = {
|
||||
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
|
||||
deleteDatabase: vi.fn(),
|
||||
};
|
||||
(globalThis as any).caches = {
|
||||
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
112
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
112
apps/client/src/features/offline/clear-offline-cache.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { del } from "idb-keyval";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
OFFLINE_CACHE_KEY,
|
||||
freezeOfflinePersistence,
|
||||
unfreezeOfflinePersistence,
|
||||
} from "./query-persister";
|
||||
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
* Best-effort purge of all of the current user's offline data from the browser.
|
||||
*
|
||||
* On logout the previous user's private data would otherwise linger locally and
|
||||
* be readable by the next person on the device. This clears the three offline
|
||||
* stores the app writes:
|
||||
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
|
||||
* `OFFLINE_CACHE_KEY`),
|
||||
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
|
||||
* y-indexeddb in make-offline.ts), and
|
||||
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
|
||||
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
|
||||
* rule was removed — offline reads come from the persisted RQ cache), so
|
||||
* this is now a defensive cleanup for caches left by older app versions.
|
||||
*
|
||||
* Fully best-effort: every step is isolated so a single failure neither blocks
|
||||
* the remaining steps nor throws to the caller (logout must never be blocked on
|
||||
* cache cleanup). Callers may ignore the resolved value.
|
||||
*
|
||||
* Limitations:
|
||||
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
|
||||
* is unavailable in some browsers (notably Firefox). There we skip silently;
|
||||
* those `page.<id>` databases are then left in place.
|
||||
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
|
||||
* service-worker-capable browsers).
|
||||
*/
|
||||
export async function clearOfflineCache(): Promise<void> {
|
||||
// Freeze the throttled persister BEFORE touching the cache so the
|
||||
// queryClient.clear() below cannot trigger a late re-write of the (still
|
||||
// nearly-full) dehydrated snapshot after we del() the key — which would
|
||||
// otherwise resurrect the previous user's persisted data in IndexedDB.
|
||||
// Re-enabled in `finally` so the next (sign-in) session persists normally.
|
||||
freezeOfflinePersistence();
|
||||
|
||||
try {
|
||||
// 1a. Drop the in-memory query cache immediately.
|
||||
try {
|
||||
queryClient.clear();
|
||||
} catch {
|
||||
// best-effort: ignore in-memory cache reset failures
|
||||
}
|
||||
|
||||
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||
try {
|
||||
await del(OFFLINE_CACHE_KEY);
|
||||
} catch {
|
||||
// best-effort: ignore persisted-cache deletion failures
|
||||
}
|
||||
|
||||
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
|
||||
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
|
||||
// it is missing we cannot enumerate the page databases, so we skip silently.
|
||||
try {
|
||||
if (
|
||||
typeof indexedDB !== "undefined" &&
|
||||
typeof indexedDB.databases === "function"
|
||||
) {
|
||||
const dbs = await indexedDB.databases();
|
||||
for (const db of dbs) {
|
||||
const name = db?.name;
|
||||
if (typeof name !== "string" || !name.startsWith(PAGE_YDOC_NAME_PREFIX))
|
||||
continue;
|
||||
try {
|
||||
// Fire-and-forget delete; await a thin wrapper so a slow delete does
|
||||
// not race the page teardown, but never reject on it.
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase(name);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
request.onblocked = () => resolve();
|
||||
});
|
||||
} catch {
|
||||
// best-effort per database
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort: ignore enumeration/deletion failures
|
||||
}
|
||||
|
||||
// 3. Clear any legacy service worker API cache. Current builds no longer
|
||||
// create it, but an older client may have left an "api-get-cache" entry
|
||||
// (Workbox may prefix the name), so match by substring rather than exact name.
|
||||
try {
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.includes("api-get-cache"))
|
||||
.map((key) => caches.delete(key)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// best-effort: ignore Cache Storage failures
|
||||
}
|
||||
} finally {
|
||||
// Re-enable persistence for the next session (sign-in continues running in
|
||||
// the same tab; logout reloads via window.location.replace, so this is a
|
||||
// harmless no-op there).
|
||||
unfreezeOfflinePersistence();
|
||||
}
|
||||
}
|
||||
373
apps/client/src/features/offline/make-offline.test.ts
Normal file
373
apps/client/src/features/offline/make-offline.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// vi.mock factories are hoisted above imports, so any spy they reference must be
|
||||
// declared with vi.hoisted (which is hoisted as well). These shared spies are
|
||||
// inspected by the assertions below.
|
||||
const h = vi.hoisted(() => ({
|
||||
ydocDestroy: vi.fn(),
|
||||
idbDestroy: vi.fn(),
|
||||
providerOn: vi.fn(),
|
||||
providerOff: vi.fn(),
|
||||
providerDestroy: vi.fn(),
|
||||
}));
|
||||
|
||||
// The module under test imports the app entry at load time — it must be mocked.
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
getPageById: vi.fn(),
|
||||
getPageBreadcrumbs: vi.fn(),
|
||||
getSidebarPages: vi.fn(),
|
||||
getAllSidebarPages: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||
getSpaceById: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
// Use the `function` form (not an arrow) so Vitest binds the constructor return
|
||||
// value when the module under test calls `new Y.Doc()` etc.
|
||||
vi.mock("yjs", () => ({
|
||||
Doc: vi.fn(function () {
|
||||
return { destroy: h.ydocDestroy };
|
||||
}),
|
||||
}));
|
||||
vi.mock("y-indexeddb", () => ({
|
||||
IndexeddbPersistence: vi.fn(function () {
|
||||
return { destroy: h.idbDestroy };
|
||||
}),
|
||||
}));
|
||||
vi.mock("@hocuspocus/provider", () => ({
|
||||
HocuspocusProvider: vi.fn(function () {
|
||||
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
warmInfiniteAll,
|
||||
warmPageYdoc,
|
||||
makePageAvailableOffline,
|
||||
} from "./make-offline";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
getPageById,
|
||||
getPageBreadcrumbs,
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service";
|
||||
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||
|
||||
const setQueryData = (queryClient as any).setQueryData as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear call history WITHOUT wiping the mock implementations the vi.mock
|
||||
// factories installed (vi.clearAllMocks would drop the constructor return
|
||||
// objects and break the provider/idb/yjs spies).
|
||||
setQueryData.mockClear();
|
||||
prefetchQuery.mockReset();
|
||||
prefetchQuery.mockResolvedValue(undefined);
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockReset();
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
|
||||
h.ydocDestroy.mockClear();
|
||||
h.idbDestroy.mockClear();
|
||||
h.providerOn.mockClear();
|
||||
h.providerOff.mockClear();
|
||||
h.providerDestroy.mockClear();
|
||||
});
|
||||
|
||||
describe("warmInfiniteAll", () => {
|
||||
it("warms a single page and writes the InfiniteData cache shape", async () => {
|
||||
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
|
||||
const fetchPage = vi.fn().mockResolvedValue(res);
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
||||
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
|
||||
pages: [res],
|
||||
pageParams: [undefined],
|
||||
});
|
||||
});
|
||||
|
||||
it("walks the cursor chain across multiple pages", async () => {
|
||||
const r0 = { items: [], meta: { nextCursor: "c1" } };
|
||||
const r1 = { items: [], meta: { nextCursor: "c2" } };
|
||||
const r2 = { items: [], meta: { nextCursor: null } };
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(r0)
|
||||
.mockResolvedValueOnce(r1)
|
||||
.mockResolvedValueOnce(r2);
|
||||
|
||||
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||
undefined,
|
||||
"c1",
|
||||
"c2",
|
||||
]);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toEqual([r0, r1, r2]);
|
||||
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||
});
|
||||
|
||||
it("caps pagination at maxPages and reports the truncation (returns false)", async () => {
|
||||
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Hitting maxPages with a cursor still pending is a truncated warm: the
|
||||
// (partial) cache is still written, but the result is reported as false.
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
const payload = setQueryData.mock.calls[0][1];
|
||||
expect(payload.pages).toHaveLength(2);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns true on success", async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
|
||||
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("reports errors (returns false) and never writes the cache on failure", async () => {
|
||||
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||
).resolves.toBe(false);
|
||||
expect(setQueryData).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makePageAvailableOffline", () => {
|
||||
const okPage = {
|
||||
id: "uuid-1",
|
||||
slugId: "slug-1",
|
||||
space: { slug: "space-slug" },
|
||||
};
|
||||
|
||||
it("returns ok:true with no failures when every step succeeds", async () => {
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, failed: [] });
|
||||
});
|
||||
|
||||
it("returns ok:false with the failed step label when a warm step fails", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Comments warm fails -> labeled "comments".
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("network"),
|
||||
);
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toContain("comments");
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Helper: the page-ids passed to the sidebar-children warm (its query key is
|
||||
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
|
||||
const warmedSidebarIds = () =>
|
||||
prefetchQuery.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
|
||||
.map((opts: any) => opts.queryKey[1]?.pageId);
|
||||
|
||||
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
|
||||
// by the ancestorId === pageId guard so it is not warmed twice), and a
|
||||
// malformed entry with no id (also skipped).
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "anc-1" },
|
||||
{ id: "uuid-1" }, // === pageId -> guard
|
||||
{ id: "anc-2" },
|
||||
{}, // no id -> skipped
|
||||
]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
const ids = warmedSidebarIds();
|
||||
// The page's own children (warmSidebarChildren(pageId)) plus each real
|
||||
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
|
||||
// NOT a second warm: uuid-1 appears once (from the page's own children call).
|
||||
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
|
||||
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
|
||||
expect(result).toEqual({ ok: true, failed: [] });
|
||||
});
|
||||
|
||||
it("dedupes repeated tree failures into a single 'tree' label", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "anc-1" },
|
||||
{ id: "anc-2" },
|
||||
]);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
|
||||
// failures); the currentUser/space prefetches still resolve.
|
||||
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
// Three node warms failed but the contract collapses them to one "tree".
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toEqual(["tree"]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
|
||||
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("network"),
|
||||
);
|
||||
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [],
|
||||
meta: { nextCursor: null },
|
||||
});
|
||||
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: "uuid-1",
|
||||
spaceId: "space-uuid",
|
||||
});
|
||||
|
||||
// The page's own children still warmed fine (prefetch resolves), so the only
|
||||
// failure is the breadcrumbs lookup.
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.failed).toEqual(["breadcrumbs"]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("warmPageYdoc", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Grab the synced handler the provider registered.
|
||||
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||
const handler = h.providerOn.mock.calls.find(
|
||||
(c) => c[0] === "synced",
|
||||
)![1] as () => void;
|
||||
|
||||
handler();
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
// Listener detached and everything cleaned up.
|
||||
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Firing the handler again must NOT re-run cleanup (settled guard).
|
||||
handler();
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves and cleans up after the timeout when synced never fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = warmPageYdoc("p1", "ws://x");
|
||||
|
||||
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
315
apps/client/src/features/offline/make-offline.ts
Normal file
315
apps/client/src/features/offline/make-offline.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import * as Y from "yjs";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import {
|
||||
getPageById,
|
||||
getPageBreadcrumbs,
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
pageKeys,
|
||||
sidebarPagesQueryOptions,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||
import { getMyInfo } from "@/features/user/services/user-service";
|
||||
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||
|
||||
/**
|
||||
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
|
||||
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
|
||||
*
|
||||
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
|
||||
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
|
||||
* spinning forever offline, and silently truncates large lists. This walks the
|
||||
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
|
||||
*
|
||||
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
|
||||
* but it is reported — the error is logged with context and `false` is returned
|
||||
* so the caller can record the failed step instead of silently succeeding.
|
||||
*
|
||||
* Returns true ONLY if the cursor chain was fully exhausted and written. If the
|
||||
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
|
||||
* the cached list is truncated AND its last page keeps a nextCursor that cannot
|
||||
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
|
||||
* that case is logged and returns false too — the caller records it as a failed
|
||||
* warm instead of a silent truncated success. The (partial) cache is still
|
||||
* written so what we did fetch is usable.
|
||||
*
|
||||
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||
*/
|
||||
export async function warmInfiniteAll<T>(
|
||||
queryKey: readonly unknown[],
|
||||
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||
maxPages = 50,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const pages: IPagination<T>[] = [];
|
||||
const pageParams: (string | undefined)[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
let exhausted = false;
|
||||
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
const res = await fetchPage(cursor);
|
||||
pages.push(res);
|
||||
pageParams.push(cursor);
|
||||
cursor = res?.meta?.nextCursor ?? undefined;
|
||||
if (!cursor) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||
|
||||
if (!exhausted) {
|
||||
// Stopped at maxPages with a cursor still pending: the list is truncated
|
||||
// and the last cached page's nextCursor is un-fetchable offline. Report it
|
||||
// as a failed warm rather than a silent truncated success.
|
||||
console.error("warmInfiniteAll truncated at maxPages", {
|
||||
queryKey,
|
||||
maxPages,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("warmInfiniteAll failed", { queryKey, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakePageAvailableOfflineParams {
|
||||
pageId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
|
||||
* step succeeded; `failed` lists the labels of the steps that failed (a subset
|
||||
* of: "currentUser", "page", "space", "tree", "breadcrumbs", "comments").
|
||||
*/
|
||||
export interface MakePageAvailableOfflineResult {
|
||||
ok: boolean;
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort prefetch of a page's read queries so they get persisted to
|
||||
* IndexedDB and become readable offline.
|
||||
*
|
||||
* Each step is isolated and this function does NOT throw — a partial warm is
|
||||
* still useful. Instead of silently succeeding, every failed step is logged
|
||||
* with a label and recorded in the returned result: `{ ok, failed }` where
|
||||
* `ok` is true only if no step failed and `failed` lists the failed step
|
||||
* labels. Only meaningful while online (the underlying requests must succeed).
|
||||
*/
|
||||
export async function makePageAvailableOffline({
|
||||
pageId,
|
||||
spaceId,
|
||||
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
|
||||
const failed: string[] = [];
|
||||
|
||||
// Warm the current user (['currentUser']) so the auth-gated <Layout> can
|
||||
// hydrate offline. UserProvider blanks the whole app while useCurrentUser has
|
||||
// no data, and the offline POST /api/users/me fails as a network error, so
|
||||
// without a persisted user a pinned page still white-screens after relaunch
|
||||
// (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted
|
||||
// cache actually has an entry to restore.
|
||||
try {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryFn: () => getMyInfo(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: currentUser step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("currentUser");
|
||||
}
|
||||
|
||||
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
|
||||
// like usePageQuery's onData effect. Every page consumer reads
|
||||
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
|
||||
// so warming only the uuid key would leave the offline page blank.
|
||||
let page: IPage | undefined;
|
||||
try {
|
||||
page = await getPageById({ pageId });
|
||||
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
|
||||
queryClient.setQueryData(pageKeys.detail(page.id), page);
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: page step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("page");
|
||||
}
|
||||
|
||||
// Warm the space — page.tsx renders nothing until the space query resolves
|
||||
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
|
||||
// the space is actually persisted before the caller fires its toast. Shares
|
||||
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
|
||||
try {
|
||||
const spaceSlug = page?.space?.slug;
|
||||
if (spaceSlug) {
|
||||
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: space step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("space");
|
||||
}
|
||||
|
||||
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
|
||||
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
|
||||
// Fully paginated so large root levels are not truncated at 100.
|
||||
if (spaceId) {
|
||||
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
|
||||
getSidebarPages({ spaceId, cursor, limit: 100 }),
|
||||
);
|
||||
if (!ok) failed.push("tree");
|
||||
}
|
||||
|
||||
// Warm the children of the page and of every ancestor so the path to this
|
||||
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
|
||||
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
|
||||
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
|
||||
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
|
||||
// would never be read by the offline tree.
|
||||
const warmSidebarChildren = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
|
||||
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
|
||||
const params = { pageId: id, spaceId };
|
||||
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: tree node step failed", {
|
||||
pageId: id,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The page's own children.
|
||||
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
|
||||
|
||||
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
|
||||
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
|
||||
// (the UI derives the path from the tree).
|
||||
try {
|
||||
const ancestors = (await getPageBreadcrumbs(pageId)) as
|
||||
| Array<{ id?: string }>
|
||||
| undefined;
|
||||
for (const ancestor of ancestors ?? []) {
|
||||
const ancestorId = ancestor?.id;
|
||||
if (!ancestorId || ancestorId === pageId) continue;
|
||||
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("makePageAvailableOffline: breadcrumbs step failed", {
|
||||
pageId,
|
||||
error,
|
||||
});
|
||||
failed.push("breadcrumbs");
|
||||
}
|
||||
|
||||
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
|
||||
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
|
||||
// only the first page leaves the offline comments panel spinning forever on
|
||||
// pages with >100 comments. Fully paginate so the last cached page has no
|
||||
// nextCursor and the panel settles offline.
|
||||
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
|
||||
getPageComments({ pageId, cursor, limit: 100 }),
|
||||
);
|
||||
if (!commentsOk) failed.push("comments");
|
||||
|
||||
// Dedupe — the tree label can be recorded once per failed node/ancestor.
|
||||
const uniqueFailed = [...new Set(failed)];
|
||||
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
|
||||
* can open offline.
|
||||
*
|
||||
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
|
||||
* pull the server state into IndexedDB, then tears both down once synced (or
|
||||
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
|
||||
*
|
||||
* Only meaningful when online at warm time; offline it is a no-op that resolves.
|
||||
*/
|
||||
export async function warmPageYdoc(
|
||||
pageId: string,
|
||||
collabUrl: string,
|
||||
token?: string,
|
||||
): Promise<void> {
|
||||
let ydoc: Y.Doc | null = null;
|
||||
let local: IndexeddbPersistence | null = null;
|
||||
let remote: HocuspocusProvider | null = null;
|
||||
|
||||
try {
|
||||
const documentName = pageYdocName(pageId);
|
||||
ydoc = new Y.Doc();
|
||||
local = new IndexeddbPersistence(documentName, ydoc);
|
||||
remote = new HocuspocusProvider({
|
||||
url: collabUrl,
|
||||
name: documentName,
|
||||
document: ydoc,
|
||||
token,
|
||||
});
|
||||
|
||||
const provider = remote;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Clear the pending timeout and detach the listener so neither leaks
|
||||
// after we resolve.
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
try {
|
||||
provider.off("synced", finish);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Resolve once the server state has synced into the local doc...
|
||||
provider.on("synced", finish);
|
||||
// ...or give up after a short timeout so we never hang.
|
||||
timeoutId = setTimeout(finish, 8000);
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
try {
|
||||
remote?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
local?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
ydoc?.destroy();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/client/src/features/offline/offline-fallback.tsx
Normal file
45
apps/client/src/features/offline/offline-fallback.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Button, Container, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config";
|
||||
|
||||
/**
|
||||
* Shown when the authenticated app shell cannot hydrate because the current
|
||||
* user is unavailable AND there is no cached user to fall back on (e.g. an
|
||||
* offline cold boot of a page that was never warmed for offline).
|
||||
*
|
||||
* Previously UserProvider returned a bare `<></>` in this situation, which
|
||||
* white-screened the whole app on any offline reload (#237/#238). Rendering an
|
||||
* explicit "you're offline" state with a retry instead gives the user a clear,
|
||||
* non-blank fallback and a way to recover once the network returns.
|
||||
*/
|
||||
export function OfflineFallback() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("You're offline")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="sm" py={80}>
|
||||
<Stack align="center" gap="md">
|
||||
<Title order={2} ta="center">
|
||||
{t("You're offline")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="lg" ta="center">
|
||||
{t(
|
||||
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button onClick={() => window.location.reload()} variant="subtle">
|
||||
{t("Retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/client/src/features/offline/offline-mutations.test.ts
Normal file
114
apps/client/src/features/offline/offline-mutations.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query";
|
||||
|
||||
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||
const h = vi.hoisted(() => ({
|
||||
createPage: vi.fn(),
|
||||
movePage: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
createPage: h.createPage,
|
||||
movePage: h.movePage,
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
createComment: h.createComment,
|
||||
}));
|
||||
// page-query pulls in the app entry (queryClient) and a lot of UI deps via its
|
||||
// cache helpers; we only need invalidateOnCreatePage to be a no-op here.
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
invalidateOnCreatePage: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
offlineMutationKeys,
|
||||
registerOfflineMutationDefaults,
|
||||
} from "./offline-mutations";
|
||||
|
||||
beforeEach(() => {
|
||||
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||
});
|
||||
|
||||
describe("registerOfflineMutationDefaults", () => {
|
||||
it("registers a default mutationFn for every offline mutation key", () => {
|
||||
const qc = new QueryClient();
|
||||
registerOfflineMutationDefaults(qc);
|
||||
|
||||
for (const key of Object.values(offlineMutationKeys)) {
|
||||
const defaults = qc.getMutationDefaults(key);
|
||||
expect(typeof defaults?.mutationFn).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
// The headline durability guarantee: a paused mutation dehydrated into
|
||||
// IndexedDB while offline must, after a reload, have a mutationFn so
|
||||
// resumePausedMutations() actually replays the write on reconnect.
|
||||
it("makes a rehydrated paused create replayable by resumePausedMutations", async () => {
|
||||
// 1) Simulate the offline tab: a paused create mutation gets dehydrated.
|
||||
const offlineClient = new QueryClient();
|
||||
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
});
|
||||
// Force the dehydrate-worthy paused state (offline = isPaused) with the
|
||||
// payload the user submitted before losing connectivity.
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||
|
||||
const dehydrated = dehydrate(offlineClient, {
|
||||
shouldDehydrateMutation: () => true,
|
||||
});
|
||||
expect(dehydrated.mutations).toHaveLength(1);
|
||||
// The dehydrated mutation carries NO mutationFn (functions aren't
|
||||
// serializable) — only its key + variables survive the reload.
|
||||
expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined();
|
||||
|
||||
// 2) Simulate the fresh page after reload: register defaults, then hydrate
|
||||
// the persisted paused mutation back in.
|
||||
const freshClient = new QueryClient();
|
||||
registerOfflineMutationDefaults(freshClient);
|
||||
hydrate(freshClient, dehydrated);
|
||||
|
||||
expect(freshClient.getMutationCache().getAll()).toHaveLength(1);
|
||||
|
||||
// 3) Reconnect: replay the paused mutations.
|
||||
await freshClient.resumePausedMutations();
|
||||
|
||||
// The default mutationFn ran with the persisted variables — the write is
|
||||
// NOT silently dropped.
|
||||
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||
expect(h.createPage).toHaveBeenCalledWith({
|
||||
spaceId: "s1",
|
||||
title: "Offline page",
|
||||
});
|
||||
});
|
||||
|
||||
it("makes a rehydrated paused move replayable by resumePausedMutations", async () => {
|
||||
const offlineClient = new QueryClient();
|
||||
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||
mutationKey: offlineMutationKeys.movePage,
|
||||
});
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" };
|
||||
|
||||
const dehydrated = dehydrate(offlineClient, {
|
||||
shouldDehydrateMutation: () => true,
|
||||
});
|
||||
|
||||
const freshClient = new QueryClient();
|
||||
registerOfflineMutationDefaults(freshClient);
|
||||
hydrate(freshClient, dehydrated);
|
||||
await freshClient.resumePausedMutations();
|
||||
|
||||
expect(h.movePage).toHaveBeenCalledTimes(1);
|
||||
expect(h.movePage).toHaveBeenCalledWith({
|
||||
pageId: "p1",
|
||||
parentPageId: null,
|
||||
position: "a",
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/client/src/features/offline/offline-mutations.ts
Normal file
64
apps/client/src/features/offline/offline-mutations.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { createPage, movePage } from "@/features/page/services/page-service";
|
||||
import { createComment } from "@/features/comment/services/comment-service";
|
||||
import { invalidateOnCreatePage } from "@/features/page/queries/page-query";
|
||||
import type {
|
||||
IMovePage,
|
||||
IPage,
|
||||
IPageInput,
|
||||
} from "@/features/page/types/page.types";
|
||||
import type { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
/**
|
||||
* Stable mutation keys for the offline-relevant structural mutations.
|
||||
*
|
||||
* When the browser goes offline, React Query PAUSES these mutations and the
|
||||
* PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a
|
||||
* reload-while-offline the mutation is restored, but a restored mutation has NO
|
||||
* observer (no component is mounted) — so its replay relies entirely on the
|
||||
* `mutationFn` registered via `setMutationDefaults` for its `mutationKey`.
|
||||
* Without that, `resumePausedMutations()` finds a paused mutation with no
|
||||
* `mutationFn` and silently no-ops, dropping the offline create/move/comment
|
||||
* (#237/#238). Each offline mutation hook tags itself with the matching key so
|
||||
* the rehydrated paused mutation can find its default `mutationFn` and replay.
|
||||
*/
|
||||
export const offlineMutationKeys = {
|
||||
createPage: ["create-page"] as const,
|
||||
movePage: ["move-page"] as const,
|
||||
createComment: ["create-comment"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Register default `mutationFn`s (and the minimal success side effects safe to
|
||||
* run without a mounted component) for the offline-relevant mutation keys, so a
|
||||
* paused mutation restored from IndexedDB after an offline reload is replayable
|
||||
* by `resumePausedMutations()` on reconnect.
|
||||
*
|
||||
* Called once when the QueryClient is created (see main.tsx). The hooks still
|
||||
* carry their own inline `mutationFn`/`onSuccess` for the live in-session path;
|
||||
* these defaults only take over for a rehydrated paused mutation that lost its
|
||||
* observer across the reload.
|
||||
*/
|
||||
export function registerOfflineMutationDefaults(queryClient: QueryClient): void {
|
||||
queryClient.setMutationDefaults(offlineMutationKeys.createPage, {
|
||||
mutationFn: (data: Partial<IPageInput>) => createPage(data),
|
||||
// Re-converge the sidebar tree / recent-changes from the authoritative
|
||||
// create response. Pure cache writes — safe with no component mounted.
|
||||
onSuccess: (data: IPage) => {
|
||||
invalidateOnCreatePage(data);
|
||||
},
|
||||
});
|
||||
|
||||
queryClient.setMutationDefaults(offlineMutationKeys.movePage, {
|
||||
// Replay the server-side move. The tree re-converges from the next online
|
||||
// sidebar fetch / websocket `moveTreeNode` echo, so no cache write is
|
||||
// needed here (the optimistic tree state was local-only anyway).
|
||||
mutationFn: (data: IMovePage) => movePage(data),
|
||||
});
|
||||
|
||||
queryClient.setMutationDefaults(offlineMutationKeys.createComment, {
|
||||
// Replay the server-side comment create. The comments list refetches on the
|
||||
// online reload, so the replay only needs to persist the write.
|
||||
mutationFn: (data: Partial<IComment>) => createComment(data),
|
||||
});
|
||||
}
|
||||
128
apps/client/src/features/offline/offline-resume.test.ts
Normal file
128
apps/client/src/features/offline/offline-resume.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import {
|
||||
persistQueryClientRestore,
|
||||
persistQueryClientSave,
|
||||
} from "@tanstack/react-query-persist-client";
|
||||
|
||||
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||
const h = vi.hoisted(() => ({
|
||||
createPage: vi.fn(),
|
||||
movePage: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/page/services/page-service", () => ({
|
||||
createPage: h.createPage,
|
||||
movePage: h.movePage,
|
||||
}));
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
createComment: h.createComment,
|
||||
}));
|
||||
vi.mock("@/features/page/queries/page-query", () => ({
|
||||
invalidateOnCreatePage: vi.fn(),
|
||||
}));
|
||||
|
||||
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
|
||||
// store (the actual persist -> reload -> restore path, not a hand-built blob).
|
||||
const store = new Map<string, string>();
|
||||
vi.mock("idb-keyval", () => ({
|
||||
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
|
||||
set: vi.fn((k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
del: vi.fn((k: string) => {
|
||||
store.delete(k);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
import { queryPersister } from "./query-persister";
|
||||
import {
|
||||
offlineMutationKeys,
|
||||
registerOfflineMutationDefaults,
|
||||
} from "./offline-mutations";
|
||||
|
||||
const BUSTER = "test-buster";
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// onlineManager is a global singleton; leave it in the default online state.
|
||||
onlineManager.setOnline(true);
|
||||
});
|
||||
|
||||
describe("offline paused-mutation resume across a reload", () => {
|
||||
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
|
||||
// to IndexedDB while offline, then the tab RELOADS while still offline, must
|
||||
// resume on reconnect. It exercises the real persister round-trip plus the two
|
||||
// boot-time fixes the app wiring relies on:
|
||||
// (a) onlineManager seeded to the real offline state so the later reconnect
|
||||
// is a true offline->online transition that auto-resumes, and
|
||||
// (b) resumePausedMutations() called after the persister restores (what the
|
||||
// PersistQueryClientProvider onSuccess does), with mutation defaults
|
||||
// registered BEFORE the resume so the rehydrated mutation has a fn.
|
||||
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
|
||||
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
|
||||
onlineManager.setOnline(false); // (a) boot seeded offline
|
||||
|
||||
const client1 = new QueryClient();
|
||||
registerOfflineMutationDefaults(client1);
|
||||
const observer = client1.getMutationCache().build(client1, {
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
});
|
||||
observer.state.isPaused = true;
|
||||
observer.state.status = "pending";
|
||||
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||
|
||||
await persistQueryClientSave({
|
||||
// Cast: persist-client-core and react-query may resolve to different
|
||||
// @tanstack/query-core copies whose QueryClient brands are nominally
|
||||
// incompatible (see query-persister.ts). Structurally identical at runtime.
|
||||
queryClient: client1 as any,
|
||||
persister: queryPersister,
|
||||
buster: BUSTER,
|
||||
dehydrateOptions: { shouldDehydrateMutation: () => true },
|
||||
});
|
||||
// The paused mutation is now in the persisted store.
|
||||
expect(store.size).toBe(1);
|
||||
|
||||
// --- RELOAD while still offline: fresh client restores from the SAME
|
||||
// persister. Defaults are registered BEFORE restore/resume. ---
|
||||
const client2 = new QueryClient();
|
||||
registerOfflineMutationDefaults(client2);
|
||||
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
|
||||
|
||||
await persistQueryClientRestore({
|
||||
queryClient: client2 as any,
|
||||
persister: queryPersister,
|
||||
buster: BUSTER,
|
||||
});
|
||||
expect(client2.getMutationCache().getAll()).toHaveLength(1);
|
||||
|
||||
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
|
||||
// the mutation must stay paused and NOT fire yet.
|
||||
await client2.resumePausedMutations();
|
||||
expect(h.createPage).not.toHaveBeenCalled();
|
||||
|
||||
// --- RECONNECT: the offline->online transition auto-resumes the paused
|
||||
// mutation and its registered default mutationFn finally fires. ---
|
||||
onlineManager.setOnline(true);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(h.createPage).toHaveBeenCalledWith({
|
||||
spaceId: "s1",
|
||||
title: "Offline page",
|
||||
});
|
||||
|
||||
client2.unmount();
|
||||
});
|
||||
});
|
||||
48
apps/client/src/features/offline/persist-roots.guard.test.ts
Normal file
48
apps/client/src/features/offline/persist-roots.guard.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// The query modules transitively import the app entry (@/main.tsx) for the
|
||||
// shared queryClient; mock it so importing the key factories has no side effects.
|
||||
import { vi } from "vitest";
|
||||
vi.mock("@/main.tsx", () => ({
|
||||
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
|
||||
}));
|
||||
|
||||
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
|
||||
import { pageKeys } from "@/features/page/queries/page-query";
|
||||
import { spaceKeys } from "@/features/space/queries/space-query";
|
||||
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||
|
||||
/**
|
||||
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
|
||||
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
|
||||
* factory's root is renamed without updating the persist registry — or vice
|
||||
* versa — offline persist/warm silently breaks (persisted keys never match the
|
||||
* live queries). This turns that silent regression into a red build.
|
||||
*
|
||||
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
|
||||
*/
|
||||
function rootOf(key: readonly unknown[]): string {
|
||||
return String(key[0]);
|
||||
}
|
||||
|
||||
const FACTORY_ROOTS = new Set<string>([
|
||||
rootOf(pageKeys.detail("x")),
|
||||
rootOf(pageKeys.sidebar({})),
|
||||
rootOf(pageKeys.rootSidebar("x")),
|
||||
rootOf(pageKeys.breadcrumbs("x")),
|
||||
rootOf(pageKeys.recentChanges("x")),
|
||||
rootOf(spaceKeys.detail("x")),
|
||||
rootOf(spaceKeys.list()),
|
||||
rootOf(RQ_KEY("x")),
|
||||
rootOf(userKeys.currentUser()),
|
||||
]);
|
||||
|
||||
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
|
||||
it("maps every persisted root to an exported factory root", () => {
|
||||
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
|
||||
(root) => !FACTORY_ROOTS.has(root),
|
||||
);
|
||||
expect(unbacked).toEqual([]);
|
||||
});
|
||||
});
|
||||
128
apps/client/src/features/offline/query-persister.test.ts
Normal file
128
apps/client/src/features/offline/query-persister.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
// In-memory idb-keyval so we can observe whether the persister actually writes.
|
||||
const h = vi.hoisted(() => ({
|
||||
get: vi.fn(() => Promise.resolve(undefined)),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
del: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
vi.mock("idb-keyval", () => h);
|
||||
|
||||
import {
|
||||
shouldDehydrateOfflineQuery,
|
||||
OFFLINE_PERSIST_ROOTS,
|
||||
queryPersister,
|
||||
freezeOfflinePersistence,
|
||||
unfreezeOfflinePersistence,
|
||||
} from "./query-persister";
|
||||
|
||||
// Small helper to build the structural query shape the predicate reads.
|
||||
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
|
||||
({ state: { status }, queryKey }) as any;
|
||||
|
||||
describe("shouldDehydrateOfflineQuery", () => {
|
||||
it("returns true for a successful query whose root is in the allowlist", () => {
|
||||
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(
|
||||
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
|
||||
).toBe(true);
|
||||
// currentUser is persisted so the auth-gated Layout can hydrate offline.
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the status is not success (status gate)", () => {
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty/undefined queryKey", () => {
|
||||
// String(undefined) is not a member of the allowlist.
|
||||
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
|
||||
expect(
|
||||
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||
it("contains exactly the expected 9 navigation/read roots", () => {
|
||||
const expected = [
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
"currentUser",
|
||||
];
|
||||
expect(OFFLINE_PERSIST_ROOTS.size).toBe(9);
|
||||
for (const root of expected) {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain volatile/auth keys", () => {
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
|
||||
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
|
||||
const dummyClient = {
|
||||
timestamp: Date.now(),
|
||||
buster: "",
|
||||
clientState: { mutations: [], queries: [] },
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
// Always leave persistence enabled so other tests/sessions persist normally.
|
||||
unfreezeOfflinePersistence();
|
||||
h.set.mockClear();
|
||||
});
|
||||
|
||||
it("does NOT write to storage while frozen", async () => {
|
||||
freezeOfflinePersistence();
|
||||
await queryPersister.persistClient(dummyClient);
|
||||
expect(h.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes writing to storage once unfrozen", async () => {
|
||||
freezeOfflinePersistence();
|
||||
unfreezeOfflinePersistence();
|
||||
await queryPersister.persistClient(dummyClient);
|
||||
expect(h.set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
84
apps/client/src/features/offline/query-persister.ts
Normal file
84
apps/client/src/features/offline/query-persister.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { get, set, del } from "idb-keyval";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
|
||||
// Structural subset of a TanStack Query we read when deciding what to persist.
|
||||
// We avoid importing the branded `Query` class because the persist-client and
|
||||
// react-query may resolve to different `@tanstack/query-core` copies, whose
|
||||
// `Query` types are nominally incompatible (private brand). This structural
|
||||
// shape stays assignable to whichever copy the persister expects.
|
||||
type DehydratableQuery = {
|
||||
state: { status: string };
|
||||
queryKey: readonly unknown[];
|
||||
};
|
||||
|
||||
// idb-keyval key under which TanStack Query persists its dehydrated cache.
|
||||
// Exported so the logout cache-clear logic deletes the exact same key (no
|
||||
// magic-string drift between persist and purge).
|
||||
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
|
||||
|
||||
// IndexedDB-backed storage adapter for TanStack Query's async persister.
|
||||
const idbStorage = {
|
||||
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
|
||||
setItem: (key: string, value: string) => set(key, value),
|
||||
removeItem: (key: string) => del(key),
|
||||
};
|
||||
|
||||
const basePersister = createAsyncStoragePersister({
|
||||
storage: idbStorage,
|
||||
key: OFFLINE_CACHE_KEY,
|
||||
throttleTime: 1000,
|
||||
});
|
||||
|
||||
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
|
||||
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
|
||||
// (queryClient.clear()) fires `removed` cache events, each of which the persist
|
||||
// subscription turns into a throttled persistClient call. The FIRST such call
|
||||
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
|
||||
// del() that clears the key, resurrecting the previous user's data (~180KB) in
|
||||
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
|
||||
// afterwards so the next (sign-in) session persists normally. See
|
||||
// clear-offline-cache.ts.
|
||||
let persistFrozen = false;
|
||||
|
||||
export function freezeOfflinePersistence(): void {
|
||||
persistFrozen = true;
|
||||
}
|
||||
|
||||
export function unfreezeOfflinePersistence(): void {
|
||||
persistFrozen = false;
|
||||
}
|
||||
|
||||
export const queryPersister = {
|
||||
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
|
||||
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
|
||||
restoreClient: () => basePersister.restoreClient(),
|
||||
removeClient: () => basePersister.removeClient(),
|
||||
};
|
||||
|
||||
// Only navigation/read query roots are persisted for offline reading.
|
||||
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
|
||||
//
|
||||
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
|
||||
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
|
||||
// network error. Without the persisted/hydrated user the gate blanked every
|
||||
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
|
||||
// user's own profile (already mirrored to localStorage["currentUser"]), so
|
||||
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
|
||||
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
"root-sidebar-pages",
|
||||
"breadcrumbs",
|
||||
"comments",
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
"currentUser",
|
||||
]);
|
||||
|
||||
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||
return (
|
||||
query.state.status === "success" &&
|
||||
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconPrinter,
|
||||
IconCloud,
|
||||
IconCloudCheck,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrash,
|
||||
@@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
isLocalSyncedAtom,
|
||||
isRemoteSyncedAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
@@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
function ConnectionWarning() {
|
||||
const { t } = useTranslation();
|
||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||
yjsConnectionStatus,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisconnected) {
|
||||
if (!timeoutRef.current) {
|
||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||
@@ -430,7 +436,7 @@ function ConnectionWarning() {
|
||||
}
|
||||
setShowWarning(false);
|
||||
}
|
||||
}, [yjsConnectionStatus]);
|
||||
}, [isDisconnected]);
|
||||
|
||||
// Cleanup only on unmount
|
||||
useEffect(() => {
|
||||
@@ -441,22 +447,59 @@ function ConnectionWarning() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showWarning) return null;
|
||||
// State (1): offline/disconnected — changes are kept locally. Preserve the
|
||||
// existing >5s debounce before surfacing this state.
|
||||
if (isDisconnected) {
|
||||
if (!showWarning) return null;
|
||||
|
||||
const offlineLabel = t(
|
||||
"Offline — changes are saved locally and will sync when you reconnect",
|
||||
);
|
||||
return (
|
||||
<Tooltip label={offlineLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="red"
|
||||
role="status"
|
||||
aria-label={offlineLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// State (2): connected but the remote replica is not fully caught up yet.
|
||||
if (!isRemoteSynced || !isLocalSynced) {
|
||||
const syncingLabel = t("Syncing changes…");
|
||||
return (
|
||||
<Tooltip label={syncingLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="dimmed"
|
||||
role="status"
|
||||
aria-label={syncingLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconCloud size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// State (3): fully synced — subtle confirmation indicator.
|
||||
const syncedLabel = t("All changes synced");
|
||||
return (
|
||||
<Tooltip
|
||||
label={t("Real-time editor connection lost. Retrying...")}
|
||||
openDelay={250}
|
||||
withArrow
|
||||
>
|
||||
<Tooltip label={syncedLabel} openDelay={250} withArrow>
|
||||
<ThemeIcon
|
||||
variant="default"
|
||||
c="red"
|
||||
c="dimmed"
|
||||
role="status"
|
||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
||||
aria-label={syncedLabel}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<IconWifiOff size={20} stroke={2} />
|
||||
<IconCloudCheck size={20} stroke={2} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factories for page queries. The hooks below and
|
||||
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||
* runtime keys can never silently drift apart.
|
||||
*/
|
||||
export const pageKeys = {
|
||||
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
|
||||
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
|
||||
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
|
||||
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
|
||||
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
|
||||
* fetchAllAncestorChildren and the offline warm path consume this so the key,
|
||||
* queryFn and staleTime stay identical.
|
||||
*/
|
||||
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
|
||||
queryOptions({
|
||||
queryKey: pageKeys.sidebar(params),
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
export function usePageQuery(
|
||||
pageInput: Partial<IPageInput>,
|
||||
): UseQueryResult<IPage, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["pages", pageInput.pageId],
|
||||
queryKey: pageKeys.detail(pageInput.pageId),
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
@@ -56,9 +83,9 @@ export function usePageQuery(
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(pageInput.pageId)) {
|
||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
||||
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -69,6 +96,10 @@ export function usePageQuery(
|
||||
export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
// Stable key so a paused create restored from IndexedDB after an offline
|
||||
// reload finds its default mutationFn (registerOfflineMutationDefaults) and
|
||||
// is replayed by resumePausedMutations() on reconnect instead of being lost.
|
||||
mutationKey: offlineMutationKeys.createPage,
|
||||
mutationFn: (data) => createPage(data),
|
||||
onSuccess: (data) => {
|
||||
invalidateOnCreatePage(data);
|
||||
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
|
||||
}
|
||||
|
||||
export function updatePageData(data: IPage) {
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
||||
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||
pageKeys.detail(data.slugId),
|
||||
);
|
||||
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||
|
||||
if (pageBySlug) {
|
||||
queryClient.setQueryData(["pages", data.slugId], {
|
||||
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||
...pageBySlug,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (pageById) {
|
||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
||||
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||
}
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
|
||||
});
|
||||
|
||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
||||
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||
if (cached) {
|
||||
const stamped = { ...cached, deletedAt: new Date() };
|
||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||
}
|
||||
|
||||
invalidateOnDeletePage(pageId);
|
||||
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
|
||||
|
||||
export function useMovePageMutation() {
|
||||
return useMutation<void, Error, IMovePage>({
|
||||
// Stable key so a paused move restored from IndexedDB after an offline
|
||||
// reload finds its default mutationFn and is replayed on reconnect.
|
||||
mutationKey: offlineMutationKeys.movePage,
|
||||
mutationFn: (data) => movePage(data),
|
||||
});
|
||||
}
|
||||
@@ -267,8 +303,11 @@ export function useRestorePageMutation() {
|
||||
// Replace would strip space/permissions/content and break the editor.
|
||||
const merge = (cached: IPage | undefined) =>
|
||||
cached ? { ...cached, ...restoredPage } : cached;
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||
queryClient.setQueryData<IPage>(
|
||||
pageKeys.detail(restoredPage.slugId),
|
||||
merge,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({
|
||||
@@ -283,7 +322,7 @@ export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams | null,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["sidebar-pages", data],
|
||||
queryKey: pageKeys.sidebar(data),
|
||||
enabled: !!data?.pageId || !!data?.spaceId,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||
@@ -294,7 +333,7 @@ export function useGetSidebarPagesQuery(
|
||||
|
||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return getSidebarPages({
|
||||
spaceId: data.spaceId,
|
||||
@@ -320,7 +359,7 @@ export function usePageBreadcrumbsQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["breadcrumbs", pageId],
|
||||
queryKey: pageKeys.breadcrumbs(pageId),
|
||||
queryFn: () => getPageBreadcrumbs(pageId),
|
||||
enabled: !!pageId,
|
||||
});
|
||||
@@ -332,10 +371,12 @@ export async function fetchAllAncestorChildren(
|
||||
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||
opts?: { fresh?: boolean },
|
||||
) {
|
||||
// not using a hook here, so we can call it inside a useEffect hook
|
||||
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
|
||||
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
|
||||
// this fetch never drift, but override staleTime for the `fresh` reconnect
|
||||
// refresh (#159 #8), which must force a server refetch (staleTime 0).
|
||||
const response = await queryClient.fetchQuery({
|
||||
queryKey: ["sidebar-pages", params],
|
||||
queryFn: () => getAllSidebarPages(params),
|
||||
...sidebarPagesQueryOptions(params),
|
||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||
});
|
||||
|
||||
@@ -345,7 +386,7 @@ export async function fetchAllAncestorChildren(
|
||||
|
||||
export function useRecentChangesQuery(spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
@@ -416,12 +457,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
let queryKey: QueryKey = null;
|
||||
if (data.parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
||||
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||
} else {
|
||||
queryKey = [
|
||||
"sidebar-pages",
|
||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
||||
];
|
||||
queryKey = pageKeys.sidebar({
|
||||
pageId: data.parentPageId,
|
||||
spaceId: data.spaceId,
|
||||
});
|
||||
}
|
||||
|
||||
//update all sidebar pages
|
||||
@@ -481,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update root sidebar pages haschildern
|
||||
const rootSideBarMatches = queryClient.getQueriesData({
|
||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||
exact: false,
|
||||
});
|
||||
|
||||
@@ -505,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", data.spaceId],
|
||||
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,9 +560,9 @@ export function invalidateOnUpdatePage(
|
||||
invalidatePageTree();
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
queryKey = pageKeys.rootSidebar(spaceId);
|
||||
} else {
|
||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
||||
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||
}
|
||||
//update all sidebar pages
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
@@ -544,7 +585,7 @@ export function invalidateOnUpdatePage(
|
||||
|
||||
//update recent changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["recent-changes", spaceId],
|
||||
queryKey: pageKeys.recentChanges(spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -559,8 +600,8 @@ export function updateCacheOnMovePage(
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||
oldQueryKey,
|
||||
@@ -580,7 +621,7 @@ export function updateCacheOnMovePage(
|
||||
if (oldParentId !== null) {
|
||||
const oldParentCache = queryClient.getQueryData<
|
||||
InfiniteData<IPagination<IPage>>
|
||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
||||
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||
|
||||
const remainingChildren =
|
||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||
@@ -618,8 +659,8 @@ export function updateCacheOnMovePage(
|
||||
// Add page to new parent's cache
|
||||
const newQueryKey =
|
||||
newParentId === null
|
||||
? ["root-sidebar-pages", spaceId]
|
||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
||||
? pageKeys.rootSidebar(spaceId)
|
||||
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||
|
||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||
newQueryKey,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClockHour4,
|
||||
IconCloudDownload,
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
IconFileExport,
|
||||
@@ -35,6 +36,12 @@ import {
|
||||
useToggleTemplateMutation,
|
||||
useToggleTemporaryMutation,
|
||||
} from "@/features/page-embed/queries/page-embed-query";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
makePageAvailableOffline,
|
||||
warmPageYdoc,
|
||||
} from "@/features/offline/make-offline";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||
@@ -72,6 +79,46 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const isTemplate = !!node.isTemplate;
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
const isTemporary = !!node.temporaryExpiresAt;
|
||||
const { data: collabQuery } = useCollabToken();
|
||||
|
||||
const handleMakeAvailableOffline = async () => {
|
||||
notifications.show({ message: t("Saving page for offline use...") });
|
||||
try {
|
||||
// Prefetch read queries so they get persisted to IndexedDB. The result
|
||||
// reports whether every warm step succeeded.
|
||||
const result = await makePageAvailableOffline({
|
||||
pageId: node.id,
|
||||
spaceId: node.spaceId,
|
||||
});
|
||||
// Best-effort: warm the page's Yjs document into IndexedDB.
|
||||
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
|
||||
|
||||
if (result.ok) {
|
||||
notifications.show({ message: t("Page is now available offline") });
|
||||
} else {
|
||||
// Partial warm — the page may still be partly usable offline, but some
|
||||
// queries failed to cache, so surface it as an error rather than a
|
||||
// silent success. Name the failed step(s) (AGENTS.md: errors must be
|
||||
// specific, never a bare generic string); `result.failed` carries them.
|
||||
notifications.show({
|
||||
message: `${t("Failed to make page available offline")}: ${result.failed.join(", ")}`,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
||||
// unexpected failures stay guarded here. Log the raw error and surface the
|
||||
// real cause to the user instead of a bare generic string (AGENTS.md).
|
||||
console.error("handleMakeAvailableOffline failed", err);
|
||||
const reason =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? (err instanceof Error ? err.message : String(err));
|
||||
notifications.show({
|
||||
message: `${t("Failed to make page available offline")}: ${reason}`,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTemplate = async () => {
|
||||
const next = !isTemplate;
|
||||
@@ -228,6 +275,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
{t("Export")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCloudDownload size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleMakeAvailableOffline();
|
||||
}}
|
||||
>
|
||||
{t("Make available offline")}
|
||||
</Menu.Item>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu.Item
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
useCreatePageMutation,
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
updateCacheOnMovePage,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -27,7 +26,6 @@ export type UseTreeMutation = {
|
||||
parentId: string | null,
|
||||
opts?: { temporary?: boolean },
|
||||
) => Promise<void>;
|
||||
handleRename: (id: string, name: string) => Promise<void>;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -39,7 +37,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
// children) and then immediately invokes a handler.
|
||||
const store = useStore();
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
@@ -205,20 +202,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (id: string, name: string) => {
|
||||
setData((prev) =>
|
||||
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
|
||||
);
|
||||
try {
|
||||
await updatePageMutation.mutateAsync({ pageId: id, title: name });
|
||||
} catch (error) {
|
||||
console.error("Error updating page title:", error);
|
||||
}
|
||||
},
|
||||
[updatePageMutation, setData],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
const node = treeModel.find(
|
||||
@@ -264,7 +247,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||
);
|
||||
|
||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
||||
return { handleMove, handleCreate, handleDelete };
|
||||
}
|
||||
|
||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
keepPreviousData,
|
||||
queryOptions,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
|
||||
import { useEffect } from "react";
|
||||
import { validate as isValidUuid } from "uuid";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factories for space queries. The hooks below and
|
||||
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||
* runtime keys can never silently drift apart.
|
||||
*/
|
||||
export const spaceKeys = {
|
||||
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
|
||||
list: (params?: QueryParams) => ["spaces", params] as const,
|
||||
members: (spaceId: string, query?: string) =>
|
||||
["spaceMembers", spaceId, query] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared queryOptions for fetching a space by id/slug. Both
|
||||
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
|
||||
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
|
||||
* prefetchQuery ignores it anyway and the warm path always passes a real id;
|
||||
* the hook reapplies `enabled` itself.)
|
||||
*/
|
||||
export const spaceByIdQueryOptions = (spaceId: string) =>
|
||||
queryOptions({
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
export function useGetSpacesQuery(
|
||||
params?: QueryParams,
|
||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["spaces", params],
|
||||
queryKey: spaceKeys.list(params),
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
|
||||
|
||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryKey: spaceKeys.detail(spaceId),
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
if (isValidUuid(spaceId)) {
|
||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||
} else {
|
||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
||||
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||
}
|
||||
}
|
||||
}, [query.data]);
|
||||
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||
}
|
||||
|
||||
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
||||
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
|
||||
// a 5min staleTime which would let this prefetch skip fetching fresh data;
|
||||
// prefetchSpace must always refetch (default staleTime: 0).
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["space", spaceSlug],
|
||||
queryKey: spaceKeys.detail(spaceSlug),
|
||||
queryFn: () => getSpaceById(spaceSlug),
|
||||
});
|
||||
|
||||
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<ISpace, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["space", spaceId],
|
||||
queryFn: () => getSpaceById(spaceId),
|
||||
...spaceByIdQueryOptions(spaceId),
|
||||
enabled: !!spaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
|
||||
onSuccess: (data, variables) => {
|
||||
notifications.show({ message: t("Space updated successfully") });
|
||||
|
||||
const space = queryClient.getQueryData([
|
||||
"space",
|
||||
variables.spaceId,
|
||||
]) as ISpace;
|
||||
const space = queryClient.getQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
) as ISpace;
|
||||
if (space) {
|
||||
const updatedSpace = { ...space, ...data };
|
||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
||||
queryClient.setQueryData(
|
||||
spaceKeys.detail(variables.spaceId),
|
||||
updatedSpace,
|
||||
);
|
||||
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
|
||||
|
||||
if (variables.slug) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.slug],
|
||||
queryKey: spaceKeys.detail(variables.slug),
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
|
||||
// Remove space-specific queries
|
||||
if (variables.id) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["space", variables.id],
|
||||
queryKey: spaceKeys.detail(variables.id),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
|
||||
query?: string,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["spaceMembers", spaceId, query],
|
||||
queryKey: spaceKeys.members(spaceId, query),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||
enabled: !!spaceId,
|
||||
|
||||
@@ -2,9 +2,19 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getMyInfo } from "@/features/user/services/user-service";
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
|
||||
/**
|
||||
* Centralized React Query key factory for current-user queries. This hook and
|
||||
* the offline warm path (features/offline/make-offline.ts) share it so the
|
||||
* runtime key can never silently drift, and the OFFLINE_PERSIST_ROOTS guard
|
||||
* test can assert the persisted "currentUser" root maps to a real factory.
|
||||
*/
|
||||
export const userKeys = {
|
||||
currentUser: () => ["currentUser"] as const,
|
||||
};
|
||||
|
||||
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
||||
return useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryFn: async () => {
|
||||
return await getMyInfo();
|
||||
},
|
||||
|
||||
118
apps/client/src/features/user/user-provider.test.tsx
Normal file
118
apps/client/src/features/user/user-provider.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
|
||||
// Control useCurrentUser per test; stub the rest of UserProvider's network/
|
||||
// socket dependencies so we only exercise its render-gating logic.
|
||||
const h = vi.hoisted(() => ({ useCurrentUser: vi.fn() }));
|
||||
|
||||
vi.mock("@/features/user/hooks/use-current-user", () => ({
|
||||
default: h.useCurrentUser,
|
||||
}));
|
||||
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||
useCollabToken: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/features/websocket/use-query-subscription.ts", () => ({
|
||||
useQuerySubscription: () => {},
|
||||
}));
|
||||
vi.mock("@/features/websocket/use-tree-socket.ts", () => ({
|
||||
useTreeSocket: () => {},
|
||||
}));
|
||||
vi.mock("@/features/notification/hooks/use-notification-socket.ts", () => ({
|
||||
useNotificationSocket: () => {},
|
||||
}));
|
||||
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||
vi.mock("@/features/user/connect-resync.ts", () => ({
|
||||
makeConnectHandler: () => () => {},
|
||||
}));
|
||||
vi.mock("socket.io-client", () => ({
|
||||
io: () => ({ on: vi.fn(), disconnect: vi.fn() }),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (k: string) => k,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
language: "en-US",
|
||||
resolvedLanguage: "en-US",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { UserProvider } from "./user-provider";
|
||||
|
||||
const networkError = { message: "Network Error" }; // axios network error: no `response`
|
||||
|
||||
function renderProvider() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter>
|
||||
<MantineProvider>
|
||||
<UserProvider>
|
||||
<div data-testid="app-child">app content</div>
|
||||
</UserProvider>
|
||||
</MantineProvider>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.useCurrentUser.mockReset();
|
||||
});
|
||||
|
||||
describe("UserProvider offline render-gating", () => {
|
||||
it("renders the app (cached children) when useCurrentUser errors offline but a cached user exists", () => {
|
||||
// Offline reload: the persisted ['currentUser'] cache hydrates `data`, but
|
||||
// the background POST /api/users/me refetch fails as a network error.
|
||||
h.useCurrentUser.mockReturnValue({
|
||||
data: {
|
||||
user: { id: "u1", locale: "en" },
|
||||
workspace: { id: "w1" },
|
||||
},
|
||||
isLoading: false,
|
||||
error: networkError,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
renderProvider();
|
||||
|
||||
// The cached app must render — NOT a blank fragment (#237/#238).
|
||||
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||
expect(screen.queryByText("You're offline")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the offline fallback (not a blank fragment) when erroring with no cached user", () => {
|
||||
h.useCurrentUser.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: networkError,
|
||||
isError: true,
|
||||
});
|
||||
|
||||
const { container } = renderProvider();
|
||||
|
||||
// Previously this returned `<></>` — a blank white screen. Now it must show
|
||||
// an explicit offline fallback.
|
||||
expect(screen.getByText("You're offline")).toBeDefined();
|
||||
expect(screen.queryByTestId("app-child")).toBeNull();
|
||||
expect(container.textContent?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders the app normally on a successful currentUser load", () => {
|
||||
h.useCurrentUser.mockReturnValue({
|
||||
data: {
|
||||
user: { id: "u1", locale: "en" },
|
||||
workspace: { id: "w1" },
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderProvider();
|
||||
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||
|
||||
@@ -70,14 +71,30 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||
}, [i18n.language, i18n.resolvedLanguage]);
|
||||
|
||||
if (isLoading) return <></>;
|
||||
// First load with no cached user yet: render nothing briefly while the
|
||||
// persisted ['currentUser'] cache hydrates (avoids flashing the offline
|
||||
// fallback before restore). Once we have a user we render the app even if a
|
||||
// refetch is still in flight.
|
||||
if (isLoading && !data) return <></>;
|
||||
|
||||
if (isError && error?.["response"]?.status === 404) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
// We have a (possibly cached/stale) user — render the app. Offline, the
|
||||
// POST /api/users/me refetch fails as a network error, but the persisted/
|
||||
// hydrated user is enough to render the cached UI. Previously `if (error)
|
||||
// return <></>` blanked every authenticated route on an offline reload even
|
||||
// though the cached data was present (#237/#238).
|
||||
if (data) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// No user AND an error (offline cold boot of a page never warmed for offline,
|
||||
// or no persisted cache to restore): show an explicit offline fallback rather
|
||||
// than a blank white screen.
|
||||
if (error) {
|
||||
return <></>;
|
||||
return <OfflineFallback />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -65,9 +65,6 @@ export interface IWorkspaceAiSettings {
|
||||
dictation?: boolean;
|
||||
dictationStreaming?: boolean;
|
||||
publicShareAssistant?: boolean;
|
||||
// #184: detached agent runs (a run survives a browser disconnect and can be
|
||||
// reconnected to / live-followed on reopen). Gates the run-reconnect polling.
|
||||
autonomousRuns?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
|
||||
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
@@ -21,6 +22,13 @@ import {
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import {
|
||||
queryPersister,
|
||||
shouldDehydrateOfflineQuery,
|
||||
} from "@/features/offline/query-persister";
|
||||
import { registerOfflineMutationDefaults } from "@/features/offline/offline-mutations";
|
||||
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
|
||||
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -30,10 +38,30 @@ export const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Keep cached read data around long enough to be persisted/restored for offline use.
|
||||
gcTime: 1000 * 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Register default mutationFns for the offline-relevant structural mutations so
|
||||
// a paused mutation restored from IndexedDB after an offline reload still has a
|
||||
// mutationFn and is replayed by resumePausedMutations() on reconnect (instead
|
||||
// of silently no-op'ing and dropping the offline create/move/comment). MUST run
|
||||
// before any resumePausedMutations() so rehydrated paused mutations have a fn.
|
||||
registerOfflineMutationDefaults(queryClient);
|
||||
|
||||
// Seed TanStack Query's onlineManager from the REAL connectivity state at boot.
|
||||
// It defaults to `online: true` and only flips on window online/offline events,
|
||||
// so a tab that COLD-BOOTS offline would wrongly believe it is online: paused
|
||||
// mutations restored from IndexedDB would never get a later offline->online
|
||||
// transition to trigger their replay, and the offline UI affordances could not
|
||||
// tell they are offline. Seeding here makes the first real `online` event a true
|
||||
// transition that auto-resumes the rehydrated paused mutations (#120 data loss).
|
||||
if (typeof navigator !== "undefined" && "onLine" in navigator) {
|
||||
onlineManager.setOnline(navigator.onLine);
|
||||
}
|
||||
|
||||
if (isCloud() && isPostHogEnabled) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
@@ -50,15 +78,44 @@ root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: queryPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24,
|
||||
buster: APP_VERSION,
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||
},
|
||||
}}
|
||||
// After the persister finishes rehydrating, replay any paused
|
||||
// mutations restored from IndexedDB. If we are back online this fires
|
||||
// them immediately; if still offline they stay paused and TanStack's
|
||||
// onlineManager auto-resumes them on the next online transition (which
|
||||
// is now a true transition thanks to the onlineManager seeding above).
|
||||
// Without this, a paused mutation persisted while offline and then
|
||||
// reloaded would never resume and the user's work would be lost (#120).
|
||||
onSuccess={() => {
|
||||
queryClient.resumePausedMutations();
|
||||
}}
|
||||
>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
{/* Skip SW registration inside the Capacitor native WebView — the
|
||||
native shell serves assets itself; a browser SW would conflict. */}
|
||||
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
|
||||
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
|
||||
// and skipped inside the Capacitor native WebView). The earlier hand-written
|
||||
// /sw.js registration from the mobile bootstrap was removed here to avoid a
|
||||
// double registration / competing service worker.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
if ([401, 403, 404].includes(error?.["status"])) {
|
||||
// An offline fetch of a page that was never saved for offline use yields a
|
||||
// network error with NO HTTP status (status is undefined), which would
|
||||
// otherwise fall through to the generic "Error fetching page data." state.
|
||||
// When we are offline (or the failure is a network error with no status),
|
||||
// show the dedicated "You're offline — this page isn't saved for offline"
|
||||
// fallback instead, so the user understands why the page won't load.
|
||||
const httpStatus = error?.["status"];
|
||||
const isOffline =
|
||||
typeof navigator !== "undefined" && navigator.onLine === false;
|
||||
if (isOffline || (isError && httpStatus == null)) {
|
||||
return <OfflineFallback />;
|
||||
}
|
||||
if ([401, 403, 404].includes(httpStatus)) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={IconFileOff}
|
||||
|
||||
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
39
apps/client/src/pwa/is-capacitor.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { isCapacitorNativePlatform } from "./is-capacitor";
|
||||
|
||||
describe("isCapacitorNativePlatform", () => {
|
||||
afterEach(() => {
|
||||
// Keep tests isolated from each other and from the rest of the suite.
|
||||
delete (globalThis as any).Capacitor;
|
||||
});
|
||||
|
||||
it("returns false when Capacitor is undefined", () => {
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses isNativePlatform() when it is a function", () => {
|
||||
(globalThis as any).Capacitor = { isNativePlatform: () => true };
|
||||
expect(isCapacitorNativePlatform()).toBe(true);
|
||||
|
||||
(globalThis as any).Capacitor = { isNativePlatform: () => false };
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the boolean property when isNativePlatform is not a function", () => {
|
||||
(globalThis as any).Capacitor = { isNativePlatform: true };
|
||||
expect(isCapacitorNativePlatform()).toBe(true);
|
||||
|
||||
(globalThis as any).Capacitor = { isNativePlatform: false };
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when reading Capacitor throws (try/catch)", () => {
|
||||
Object.defineProperty(globalThis, "Capacitor", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
expect(isCapacitorNativePlatform()).toBe(false);
|
||||
});
|
||||
});
|
||||
23
apps/client/src/pwa/is-capacitor.ts
Normal file
23
apps/client/src/pwa/is-capacitor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Detects whether the client is running inside a Capacitor native WebView
|
||||
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
|
||||
*
|
||||
* This is a pure runtime check against the global `Capacitor` object that the
|
||||
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
|
||||
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
|
||||
* false and the Workbox service worker registers normally.
|
||||
*
|
||||
* Inside the native WebView the SW must NOT register: it would layer a redundant
|
||||
* (and conflicting) cache over Capacitor's own asset serving and interfere with
|
||||
* the native auth/CORS flow.
|
||||
*/
|
||||
export function isCapacitorNativePlatform(): boolean {
|
||||
try {
|
||||
const cap = (globalThis as any)?.Capacitor;
|
||||
return !!(cap && typeof cap.isNativePlatform === "function"
|
||||
? cap.isNativePlatform()
|
||||
: cap?.isNativePlatform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
59
apps/client/src/pwa/pwa-update-prompt.tsx
Normal file
59
apps/client/src/pwa/pwa-update-prompt.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||
|
||||
// Stable notification id so we can show/hide a single update prompt.
|
||||
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
|
||||
|
||||
/**
|
||||
* Listens for a waiting service worker and surfaces a Mantine notification
|
||||
* prompting the user to reload into the new version.
|
||||
*
|
||||
* Must be mounted inside the Mantine provider subtree (Notifications must be
|
||||
* available). Renders nothing itself.
|
||||
*/
|
||||
export function PwaUpdatePrompt() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegisterError(error) {
|
||||
// Best-effort: a failed registration must not break the app.
|
||||
console.error("Service worker registration error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!needRefresh) return;
|
||||
|
||||
notifications.show({
|
||||
id: UPDATE_NOTIFICATION_ID,
|
||||
title: t("Update available"),
|
||||
message: (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="xs"
|
||||
onClick={() => updateServiceWorker(true)}
|
||||
>
|
||||
{t("Reload")}
|
||||
</Button>
|
||||
),
|
||||
autoClose: false,
|
||||
withCloseButton: true,
|
||||
});
|
||||
|
||||
// Hide the notification when the prompt is no longer needed / on cleanup.
|
||||
return () => {
|
||||
notifications.hide(UPDATE_NOTIFICATION_ID);
|
||||
};
|
||||
}, [needRefresh, t, updateServiceWorker]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default PwaUpdatePrompt;
|
||||
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
32
apps/client/src/pwa/sw-strategy.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
|
||||
|
||||
describe("isApiPath", () => {
|
||||
it("matches the /api segment and its subtree", () => {
|
||||
expect(isApiPath("/api")).toBe(true);
|
||||
expect(isApiPath("/api/")).toBe(true);
|
||||
expect(isApiPath("/api/pages")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not over-match sibling paths", () => {
|
||||
expect(isApiPath("/apidocs")).toBe(false);
|
||||
expect(isApiPath("/apixyz")).toBe(false);
|
||||
expect(isApiPath("/")).toBe(false);
|
||||
expect(isApiPath("/pages")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCollabOrSocketPath", () => {
|
||||
it("matches the /collab and /socket.io segments and their subtrees", () => {
|
||||
expect(isCollabOrSocketPath("/collab")).toBe(true);
|
||||
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
|
||||
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
|
||||
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not over-match sibling paths", () => {
|
||||
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
|
||||
expect(isCollabOrSocketPath("/collabx")).toBe(false);
|
||||
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
|
||||
});
|
||||
});
|
||||
32
apps/client/src/pwa/sw-strategy.ts
Normal file
32
apps/client/src/pwa/sw-strategy.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Canonical service-worker routing predicates.
|
||||
*
|
||||
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
|
||||
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
|
||||
* generated service worker and CANNOT reference imported symbols. The matching
|
||||
* logic is therefore duplicated as inline regex literals in
|
||||
* apps/client/vite.config.ts. This module is the testable source of truth, and
|
||||
* the two MUST be kept in sync. This duplication is intentional and is the
|
||||
* documented Workbox limitation.
|
||||
*
|
||||
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
|
||||
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
|
||||
* wrongly treated as API/realtime traffic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when `pathname` is the `/api` segment or anything beneath it.
|
||||
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
|
||||
*/
|
||||
export function isApiPath(pathname: string): boolean {
|
||||
return /^\/api(\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
|
||||
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
|
||||
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
|
||||
*/
|
||||
export function isCollabOrSocketPath(pathname: string): boolean {
|
||||
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
|
||||
}
|
||||
2
apps/client/src/vite-env.d.ts
vendored
2
apps/client/src/vite-env.d.ts
vendored
@@ -1,2 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/info" />
|
||||
declare const APP_VERSION: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import * as path from "path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
@@ -53,7 +54,55 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
injectRegister: null,
|
||||
strategies: "generateSW",
|
||||
manifest: false,
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
|
||||
navigateFallback: "index.html",
|
||||
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
|
||||
// segments are consistently excluded from the SPA fallback, mirroring
|
||||
// the runtimeCaching urlPattern regexes below.
|
||||
//
|
||||
// `/share`, `/mcp`, `/l`, and `/robots.txt` mirror the server
|
||||
// static-serve exclude list (apps/server/src/main.ts setGlobalPrefix
|
||||
// `exclude`): robots.txt, the SEO/OG/analytics-injected public share
|
||||
// HTML, the embedded MCP endpoint, and the `l/:alias` vanity short-link
|
||||
// (a server 302 to a share page) are served by server controllers, so
|
||||
// the SW must never shadow them with the precached index.html app shell.
|
||||
// For `/l/:alias` the client router has NO matching route, so serving
|
||||
// the app shell would dead-end on Error404 and break the public link;
|
||||
// it must reach the server to perform the redirect.
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api(\/|$)/,
|
||||
/^\/collab(\/|$)/,
|
||||
/^\/socket\.io(\/|$)/,
|
||||
/^\/share(\/|$)/,
|
||||
/^\/mcp(\/|$)/,
|
||||
/^\/l(\/|$)/,
|
||||
/^\/robots\.txt$/,
|
||||
],
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
|
||||
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
|
||||
// functions standalone into the generated service worker, so they cannot
|
||||
// import the module — the matching logic is intentionally duplicated as
|
||||
// self-contained inline regex literals anchored to a path segment boundary.
|
||||
runtimeCaching: [
|
||||
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||
// All /api stays network-only; offline reads come from the persisted
|
||||
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
|
||||
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"@nestjs/platform-fastify": "^11.1.19",
|
||||
"@nestjs/platform-socket.io": "^11.1.19",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.19",
|
||||
|
||||
@@ -24,7 +24,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
||||
import {
|
||||
CollaborationHandler,
|
||||
CollabEventHandlers,
|
||||
writeTitleFragment,
|
||||
} from './collaboration.handler';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationGateway {
|
||||
@@ -149,6 +152,70 @@ export class CollaborationGateway {
|
||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
|
||||
*
|
||||
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
|
||||
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
|
||||
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
|
||||
* through the local Hocuspocus `openDirectConnection`. The title sync
|
||||
* therefore works in BOTH single-process (no Redis) and Redis-clustered
|
||||
* deployments.
|
||||
*
|
||||
* openDirectConnection loads the doc from persistence when no editor is
|
||||
* connected, so this works whether or not an editor is currently open: the
|
||||
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
|
||||
*
|
||||
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
|
||||
* into the connection `context` so onStoreDocument sees `context.actor ===
|
||||
* 'agent'` for the resulting title store (mirrors the body/REST path). The
|
||||
* resulting title store is usually a no-op anyway — PageService already wrote
|
||||
* the same title to the page.title column, so onStoreDocument's
|
||||
* `titleText !== page.title` guard skips the column write — but we wire the
|
||||
* context for correctness regardless.
|
||||
*/
|
||||
async writePageTitle(
|
||||
pageId: string,
|
||||
title: string,
|
||||
context?: { user?: User; actor?: string; aiChatId?: string },
|
||||
): Promise<void> {
|
||||
const documentName = `page.${pageId}`;
|
||||
const connection = await this.hocuspocus.openDirectConnection(
|
||||
documentName,
|
||||
context ?? {},
|
||||
);
|
||||
try {
|
||||
// Write the new title into the in-memory 'title' fragment AND capture the
|
||||
// resulting full doc state so we can persist it directly below.
|
||||
let ydocState: Buffer | null = null;
|
||||
await connection.transact((doc) => {
|
||||
writeTitleFragment(doc, title);
|
||||
ydocState = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
});
|
||||
|
||||
// F1 (variant C): persist the 'title' fragment to `page.ydoc` DIRECTLY,
|
||||
// bypassing onStoreDocument. PageService.update already wrote the new title
|
||||
// to the page.title COLUMN before calling this, so onStoreDocument's no-op
|
||||
// fast-path (titleText === column) would NOT persist the in-memory fragment
|
||||
// on disconnect — leaving the stored ydoc with the OLD title, which a later
|
||||
// body edit would then revert the column back to. Writing the ydoc here
|
||||
// makes BOTH column and persisted fragment consistent (NEW = NEW).
|
||||
//
|
||||
// Safe with or without a live editor: the write is idempotent and carries
|
||||
// no tree snapshot (no double broadcast); when an editor is connected, the
|
||||
// normal onStoreDocument flow still persists the (superset) state later and
|
||||
// the live clients receive the title change through the transact above.
|
||||
if (ydocState) {
|
||||
await this.persistenceExtension.persistTitleFragmentYdoc(
|
||||
pageId,
|
||||
ydocState,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
*Can be used before calling openDirectConnection directly
|
||||
*/
|
||||
|
||||
155
apps/server/src/collaboration/collaboration.handler.spec.ts
Normal file
155
apps/server/src/collaboration/collaboration.handler.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { writeTitleFragment } from './collaboration.handler';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
// Read the plain text held in the doc's 'title' XmlFragment, the same way
|
||||
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
|
||||
const readTitleText = (doc: Y.Doc): string => {
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
|
||||
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
|
||||
// Seed the doc's 'title' fragment with an OLD title, like a real page.
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
expect(readTitleText(doc)).toBe('Old Title');
|
||||
|
||||
writeTitleFragment(doc, 'New Title');
|
||||
|
||||
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
|
||||
// (append) or "New TitleNew Title" (duplication). A single heading node.
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
|
||||
expect(titleJson.content).toHaveLength(1);
|
||||
expect(titleJson.content[0].type).toBe('heading');
|
||||
});
|
||||
|
||||
it('seeds the title fragment when it started empty', () => {
|
||||
const doc = new Y.Doc();
|
||||
// Force the 'title' fragment to exist but be empty.
|
||||
doc.getXmlFragment('title');
|
||||
expect(readTitleText(doc)).toBe('');
|
||||
|
||||
writeTitleFragment(doc, 'First Title');
|
||||
|
||||
expect(readTitleText(doc)).toBe('First Title');
|
||||
});
|
||||
|
||||
it('does not corrupt the body when rewriting the title', () => {
|
||||
// A doc with both a body and an old title; the body must survive untouched.
|
||||
const doc = new Y.Doc();
|
||||
const bodyDoc = TiptapTransformer.toYdoc(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
|
||||
],
|
||||
},
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
|
||||
|
||||
writeTitleFragment(doc, 'New');
|
||||
|
||||
expect(readTitleText(doc)).toBe('New');
|
||||
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
|
||||
expect(jsonToText(bodyJson)).toContain('body text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
|
||||
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
|
||||
// method must drive the clear+seed through that direct connection (NOT through
|
||||
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
|
||||
const makeGateway = (doc: Y.Doc) => {
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||
fn(doc);
|
||||
});
|
||||
const openDirectConnection = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ transact, disconnect });
|
||||
|
||||
const gateway = Object.create(CollaborationGateway.prototype);
|
||||
// redisSync is intentionally null — this is the no-Redis scenario.
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
|
||||
// later body edit can't revert the rename (see title-rename-durability.spec).
|
||||
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
|
||||
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
|
||||
|
||||
return {
|
||||
gateway,
|
||||
openDirectConnection,
|
||||
transact,
|
||||
disconnect,
|
||||
persistTitleFragmentYdoc,
|
||||
};
|
||||
};
|
||||
|
||||
it('writes the new title via openDirectConnection and disconnects', async () => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||
|
||||
const { gateway, openDirectConnection, disconnect, persistTitleFragmentYdoc } =
|
||||
makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
|
||||
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.page-1',
|
||||
expect.objectContaining({ user: { id: 'u1' } }),
|
||||
);
|
||||
expect(readTitleText(doc)).toBe('New Title');
|
||||
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
|
||||
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.any(Buffer),
|
||||
);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('threads agent provenance into the connection context', async () => {
|
||||
const doc = new Y.Doc();
|
||||
const { gateway, openDirectConnection } = makeGateway(doc);
|
||||
|
||||
await gateway.writePageTitle('page-1', 'Agent Title', {
|
||||
user: { id: 'u1' },
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
});
|
||||
|
||||
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||
'page.page-1',
|
||||
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects even when the transaction throws', async () => {
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const openDirectConnection = jest.fn().mockResolvedValue({
|
||||
transact: jest.fn().mockRejectedValue(new Error('boom')),
|
||||
disconnect,
|
||||
});
|
||||
const gateway = Object.create(CollaborationGateway.prototype);
|
||||
gateway.redisSync = null;
|
||||
gateway.hocuspocus = { openDirectConnection } as any;
|
||||
|
||||
await expect(
|
||||
gateway.writePageTitle('page-1', 'X', {}),
|
||||
).rejects.toThrow('boom');
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
@@ -13,6 +14,35 @@ export type CollabEventHandlers = ReturnType<
|
||||
CollaborationHandler['getHandlers']
|
||||
>;
|
||||
|
||||
/**
|
||||
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
|
||||
*
|
||||
* Used by the gateway's direct `writePageTitle` method to write a new page
|
||||
* title INTO the page's Yjs 'title' fragment. The title lives in the same
|
||||
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
|
||||
* rename that only updated the page.title DB column would be reverted on the
|
||||
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
|
||||
* The whole fragment is replaced (no merge/append),
|
||||
* mirroring the 'replace' body path: the new title fully supersedes the old.
|
||||
*
|
||||
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
|
||||
* fragment, a REST/MCP rename arriving while a user is actively editing the
|
||||
* title in an open editor WILL overwrite that in-progress edit. This is
|
||||
* acceptable — the title is a short, rarely-concurrently-edited field — and is
|
||||
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
|
||||
* DB column to on the next save.
|
||||
*/
|
||||
export function writeTitleFragment(doc: Y.Doc, title: string): void {
|
||||
const titleFragment = doc.getXmlFragment('title');
|
||||
|
||||
if (titleFragment.length > 0) {
|
||||
titleFragment.delete(0, titleFragment.length);
|
||||
}
|
||||
|
||||
const newTitleDoc = buildTitleSeedYdoc(title);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CollaborationHandler {
|
||||
private readonly logger = new Logger(CollaborationHandler.name);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToNode,
|
||||
prosemirrorNodeToYElement,
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
} from './collaboration.util';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
@@ -241,3 +244,43 @@ describe('prosemirrorNodeToYElement', () => {
|
||||
expect(element.get(1).get(0).toString()).toBe('two');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTitleSeedYdoc', () => {
|
||||
it('builds a level-1 heading carrying the title text', () => {
|
||||
const doc = buildTitleSeedYdoc('Hello World');
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
|
||||
const first = json.content?.[0];
|
||||
expect(first.type).toBe('heading');
|
||||
expect(first.attrs.level).toBe(1);
|
||||
expect(jsonToText(json).trim()).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('produces a non-empty title fragment for a non-empty title', () => {
|
||||
const doc = buildTitleSeedYdoc('Some Title');
|
||||
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a heading with no text child for an empty title', () => {
|
||||
const doc = buildTitleSeedYdoc('');
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
|
||||
const first = json.content?.[0];
|
||||
expect(first.type).toBe('heading');
|
||||
// No text content for an empty title.
|
||||
expect(first.content ?? []).toHaveLength(0);
|
||||
expect(jsonToText(json).trim()).toBe('');
|
||||
});
|
||||
|
||||
it('round-trips a title through build -> extract -> build -> extract', () => {
|
||||
const title = 'Round Trip Title';
|
||||
const doc1 = buildTitleSeedYdoc(title);
|
||||
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
||||
|
||||
const doc2 = buildTitleSeedYdoc(text1);
|
||||
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
||||
|
||||
expect(text1).toBe(title);
|
||||
expect(text2).toBe(text1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
import { Node, Schema } from '@tiptap/pm/model';
|
||||
import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit.configure({
|
||||
@@ -143,6 +144,34 @@ export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
|
||||
* fragment named exactly 'title' (the collaborative title-editor contract with
|
||||
* the client). The ProseMirror shape is a doc with a single level-1 heading
|
||||
* whose text is the title (empty title => heading with no text child).
|
||||
*
|
||||
* The encoded state of the returned doc can be merged into a body doc via
|
||||
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
|
||||
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
|
||||
* the existing 'title' fragment to avoid the Yjs duplication trap.
|
||||
*/
|
||||
export function buildTitleSeedYdoc(title: string): Y.Doc {
|
||||
return TiptapTransformer.toYdoc(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: title ? [{ type: 'text', text: title }] : [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'title',
|
||||
tiptapExtensions,
|
||||
);
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
const schema = getSchema(tiptapExtensions);
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||
|
||||
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
|
||||
// rename) from the standalone collab process to the API process, which is the
|
||||
// single broadcast authority. Imported by both halves of the bridge:
|
||||
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
|
||||
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { PersistenceExtension } from './persistence.extension';
|
||||
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
|
||||
|
||||
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
|
||||
const bodyJson = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
||||
};
|
||||
|
||||
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
|
||||
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
|
||||
const buildDoc = () => {
|
||||
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
d.broadcastStateless = jest.fn();
|
||||
return d;
|
||||
};
|
||||
|
||||
const cloneOut = (doc: any) =>
|
||||
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
|
||||
|
||||
const addTitleFragment = (doc: any, title: string) =>
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
|
||||
describe('PersistenceExtension', () => {
|
||||
let pageRepo: any;
|
||||
let pageHistoryRepo: any;
|
||||
let trx: any;
|
||||
let db: any;
|
||||
let aiQueue: any;
|
||||
let historyQueue: any;
|
||||
let notificationQueue: any;
|
||||
let collabHistory: any;
|
||||
let transclusionService: any;
|
||||
let ext: PersistenceExtension;
|
||||
|
||||
beforeEach(() => {
|
||||
pageRepo = {
|
||||
findById: jest.fn(),
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pageHistoryRepo = {
|
||||
findPageLastHistory: jest.fn(),
|
||||
saveHistory: jest.fn(),
|
||||
};
|
||||
trx = {};
|
||||
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
|
||||
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
aiQueue as any,
|
||||
historyQueue as any,
|
||||
notificationQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('seedTitleFragment', () => {
|
||||
it('returns false for empty/whitespace/null titles', () => {
|
||||
const doc = new Y.Doc();
|
||||
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
|
||||
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
|
||||
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT re-seed an existing non-empty title fragment', () => {
|
||||
const doc = new Y.Doc();
|
||||
addTitleFragment(doc, 'Existing');
|
||||
|
||||
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
|
||||
|
||||
const text = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
expect(JSON.stringify(text)).toContain('Existing');
|
||||
expect(JSON.stringify(text)).not.toContain('Other');
|
||||
});
|
||||
|
||||
it('seeds an empty fragment from a non-empty title and returns true', () => {
|
||||
const doc = new Y.Doc();
|
||||
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
|
||||
|
||||
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
expect(JSON.stringify(json)).toContain('Hello');
|
||||
});
|
||||
|
||||
it('returns false (defensive) when reading the fragment throws', () => {
|
||||
const fakeDoc = {
|
||||
get: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStoreDocument', () => {
|
||||
const basePage = (overrides: any) => ({
|
||||
id: 'PAGE_ID',
|
||||
slugId: 'slug',
|
||||
spaceId: 'space',
|
||||
parentPageId: null,
|
||||
creatorId: 'creator',
|
||||
contributorIds: ['creator'],
|
||||
workspaceId: 'ws',
|
||||
title: 'whatever',
|
||||
content: null,
|
||||
lastUpdatedSource: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
|
||||
|
||||
it('no-op when neither body nor title changed', async () => {
|
||||
const document = buildDoc();
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'hello title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||
expect(collabHistory.addContributors).not.toHaveBeenCalled();
|
||||
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('title-only change persists the title without body side-effects', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, 'New Title');
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'Old Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].title).toBe('New Title');
|
||||
expect(call[0].ydoc).toBeDefined();
|
||||
expect(call[0].contributorIds).toBeDefined();
|
||||
expect('content' in call[0]).toBe(false);
|
||||
// Title-only must not touch the body-authorship provenance.
|
||||
expect('lastUpdatedSource' in call[0]).toBe(false);
|
||||
expect(call[1]).toBe('PAGE_ID');
|
||||
expect(call[3].treeUpdate.title).toBe('New Title');
|
||||
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
|
||||
expect(collabHistory.addContributors).toHaveBeenCalledWith(
|
||||
'PAGE_ID',
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
|
||||
expect(aiQueue.add).not.toHaveBeenCalled();
|
||||
expect(historyQueue.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
|
||||
// The client can momentarily seed the 'title' fragment as an EMPTY heading
|
||||
// (hasTitleFragment true, extracted text '') before the real title syncs.
|
||||
// Body is unchanged here, so the only candidate write is the title -> the
|
||||
// guard must turn this into a full no-op (no updatePage, no broadcast).
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
|
||||
const page = basePage({
|
||||
content: cloneOut(document),
|
||||
title: 'Real Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
// No write at all: the empty title is not authoritative and the body is
|
||||
// unchanged, so onStoreDocument must take the no-op fast path.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, ''); // empty title fragment
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] }, // different body -> bodyChanged
|
||||
title: 'Real Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
// Body is persisted, but the title is NOT included (empty == not
|
||||
// authoritative) and no tree update is broadcast for the title.
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect('title' in call[0]).toBe(false);
|
||||
expect(call[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('body + title change persists both with full body side-effects', async () => {
|
||||
const document = buildDoc();
|
||||
addTitleFragment(document, 'New Title');
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] },
|
||||
title: 'Old Title',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect(call[0].title).toBe('New Title');
|
||||
expect(call[0].ydoc).toBeDefined();
|
||||
expect(call[0].lastUpdatedSource).toBe('user');
|
||||
expect(call[3].treeUpdate).toBeDefined();
|
||||
|
||||
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||
expect(aiQueue.add).toHaveBeenCalled();
|
||||
expect(historyQueue.add).toHaveBeenCalled();
|
||||
expect(collabHistory.addContributors).toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('body-only change persists the body without a tree update', async () => {
|
||||
const document = buildDoc();
|
||||
const page = basePage({
|
||||
content: { type: 'doc', content: [] },
|
||||
title: 'whatever',
|
||||
});
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
context,
|
||||
} as any);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(call[0].content).toBeTruthy();
|
||||
expect('title' in call[0]).toBe(false);
|
||||
// No treeUpdate for a body-only save.
|
||||
expect(call[3]).toBeUndefined();
|
||||
|
||||
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
|
||||
expect(aiQueue.add).toHaveBeenCalled();
|
||||
expect(historyQueue.add).toHaveBeenCalled();
|
||||
expect(document.broadcastStateless).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLoadDocument', () => {
|
||||
it('returns early (no DB read) when the document is not empty', async () => {
|
||||
const document = { isEmpty: () => false };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined and does not persist when the page is null', async () => {
|
||||
const document = { isEmpty: () => true };
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Legacy Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
// Both the cheap pre-check and the locked re-read return the same row.
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// The locked re-read must take the row lock inside the tx.
|
||||
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||
(c: any[]) => c[1]?.withLock,
|
||||
);
|
||||
expect(lockedReadCall).toBeDefined();
|
||||
expect(lockedReadCall[1].trx).toBe(trx);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||
expect(call[1]).toBe('PAGE_ID');
|
||||
// Persist must run inside the transaction.
|
||||
expect(call[2]).toBe(trx);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Has Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// Hot path: only the cheap lock-free read, no locked re-read, no write.
|
||||
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
|
||||
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'T',
|
||||
ydoc: null,
|
||||
content: bodyJson,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
const lockedReadCall = pageRepo.findById.mock.calls.find(
|
||||
(c: any[]) => c[1]?.withLock,
|
||||
);
|
||||
expect(lockedReadCall).toBeDefined();
|
||||
expect(lockedReadCall[1].trx).toBe(trx);
|
||||
|
||||
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
|
||||
const call = pageRepo.updatePage.mock.calls[0];
|
||||
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
|
||||
expect(call[2]).toBe(trx);
|
||||
// The rebuilt doc carries the body.
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||
});
|
||||
|
||||
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
|
||||
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
|
||||
// rebuild path), but by the time we hold the lock another process has
|
||||
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
|
||||
const healed = TiptapTransformer.toYdoc(
|
||||
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
|
||||
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
|
||||
|
||||
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||
const lockedRow = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Healed Title',
|
||||
ydoc: healedYdoc,
|
||||
content: bodyJson,
|
||||
};
|
||||
pageRepo.findById
|
||||
.mockResolvedValueOnce(preCheck) // cheap pre-check
|
||||
.mockResolvedValueOnce(lockedRow); // locked re-read
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
|
||||
// seeded -> no clobbering write.
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('healed');
|
||||
});
|
||||
|
||||
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
|
||||
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||
const errSpy = jest
|
||||
.spyOn((ext as any).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
await expect(
|
||||
ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any),
|
||||
).rejects.toThrow('db down');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
|
||||
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
|
||||
const page = {
|
||||
id: 'PAGE_ID',
|
||||
title: 'Legacy Title',
|
||||
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
|
||||
content: null,
|
||||
};
|
||||
pageRepo.findById.mockResolvedValue(page);
|
||||
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
|
||||
const errSpy = jest
|
||||
.spyOn((ext as any).logger, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const document = { isEmpty: () => true };
|
||||
const result = await ext.onLoadDocument({
|
||||
documentName: 'page.PAGE_ID',
|
||||
document,
|
||||
} as any);
|
||||
|
||||
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
|
||||
expect(result).toBeTruthy();
|
||||
expect(JSON.stringify(cloneOut(result))).toContain('hello');
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import * as Y from 'yjs';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
getPageId,
|
||||
isEmptyParagraphDoc,
|
||||
jsonToText,
|
||||
@@ -116,6 +117,10 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
|
||||
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
|
||||
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
|
||||
// runs exactly once (see healUnderLock).
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
includeYdoc: true,
|
||||
@@ -127,33 +132,193 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
if (page.ydoc) {
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
|
||||
const doc = new Y.Doc();
|
||||
const dbState = new Uint8Array(page.ydoc);
|
||||
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
|
||||
|
||||
Y.applyUpdate(doc, dbState);
|
||||
return doc;
|
||||
// Legacy pages persisted their title only in the `page.title` column; the
|
||||
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
|
||||
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
|
||||
// only when that fragment is empty AND there is a non-empty column title.
|
||||
let titleSeedNeeded = false;
|
||||
try {
|
||||
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
|
||||
} catch (err) {
|
||||
// A malformed title fragment must not break loading; skip the seed.
|
||||
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
|
||||
titleSeedNeeded = false;
|
||||
}
|
||||
|
||||
if (!titleSeedNeeded) {
|
||||
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
|
||||
this.logger.debug(`ydoc loaded from db: ${pageId}`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
|
||||
// title fragment. If the persist fails we must NOT hand out an unpersisted
|
||||
// fresh-client-id seed (it could later duplicate the title), so we fall
|
||||
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
|
||||
// seed. The title just won't render until a later successful heal —
|
||||
// non-fatal, non-corrupting.
|
||||
try {
|
||||
return await this.healUnderLock(pageId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
|
||||
err,
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
// if no ydoc state in db convert json in page.content to Ydoc.
|
||||
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
|
||||
// title-seeds + persists every legacy page (content set, ydoc null) on its
|
||||
// first open, which neutralizes the duplication trap incrementally. A
|
||||
// proactive one-shot BATCH migration over all such pages could be added
|
||||
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
|
||||
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
|
||||
// exists in this repo yet, so we deliberately avoid a fragile migration.
|
||||
//
|
||||
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
|
||||
// a row lock (see healUnderLock).
|
||||
if (page.content) {
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
|
||||
const ydoc = TiptapTransformer.toYdoc(
|
||||
page.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
return ydoc;
|
||||
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
|
||||
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
|
||||
// returning it would re-arm the duplication trap. A transient DB failure
|
||||
// means the client reconnects and retries: correctness over availability.
|
||||
try {
|
||||
return await this.healUnderLock(pageId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
|
||||
* title fragment, then persist) so it runs exactly ONCE per page, closing the
|
||||
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
|
||||
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
|
||||
* process via openDirectConnection AND the standalone collab process both
|
||||
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
|
||||
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
|
||||
* row FOR UPDATE inside a transaction and re-validating state under the lock:
|
||||
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
|
||||
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
|
||||
* write rolls back and propagates out (the caller then decides refuse vs.
|
||||
* fall-back).
|
||||
*/
|
||||
private async healUnderLock(pageId: string): Promise<Y.Doc> {
|
||||
return executeTx(this.db, async (trx) => {
|
||||
const locked = await this.pageRepo.findById(pageId, {
|
||||
withLock: true,
|
||||
includeContent: true,
|
||||
includeYdoc: true,
|
||||
trx,
|
||||
});
|
||||
|
||||
const doc = new Y.Doc();
|
||||
let rebuilt = false;
|
||||
|
||||
if (locked?.ydoc) {
|
||||
// Another process already healed (or the page always had a ydoc): adopt
|
||||
// the healthy persisted state, do NOT rebuild.
|
||||
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
|
||||
} else if (locked?.content) {
|
||||
this.logger.debug(`converting json to ydoc: ${pageId}`);
|
||||
const built = TiptapTransformer.toYdoc(
|
||||
locked.content,
|
||||
'default',
|
||||
tiptapExtensions,
|
||||
);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
|
||||
rebuilt = true;
|
||||
}
|
||||
// else: no ydoc and no content -> a fresh empty doc.
|
||||
|
||||
// Idempotent, emptiness-guarded title seed (safe to call always).
|
||||
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
|
||||
|
||||
if (rebuilt || seeded) {
|
||||
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
|
||||
// error propagates out of executeTx to the caller.
|
||||
await this.pageRepo.updatePage(
|
||||
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
|
||||
pageId,
|
||||
trx,
|
||||
);
|
||||
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
|
||||
}
|
||||
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
|
||||
* pages whose persisted ydoc has no title fragment yet.
|
||||
*
|
||||
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
|
||||
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
|
||||
* fragment would re-introduce the Yjs duplication trap, so we never do it.
|
||||
* Returns true when a seed was applied (so the caller can persist).
|
||||
* Defensive: a malformed title must not break document loading.
|
||||
*/
|
||||
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
|
||||
const trimmed = (title ?? '').trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
try {
|
||||
const titleFrag = doc.get('title', Y.XmlFragment);
|
||||
if (titleFrag.length !== 0) return false;
|
||||
|
||||
const titleSeed = buildTitleSeedYdoc(title);
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
|
||||
this.logger.debug('seeded title fragment from page.title column');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an already-encoded Y.Doc state directly to `page.ydoc`, mirroring the
|
||||
* `pageRepo.updatePage({ ydoc })` write that onStoreDocument uses.
|
||||
*
|
||||
* Used by the gateway's writePageTitle (F1, variant C). A REST/MCP/agent rename
|
||||
* with no live editor writes the new title into the in-memory 'title' fragment,
|
||||
* but onStoreDocument's no-op fast-path (page.title column already equals the
|
||||
* new title) does NOT persist that in-memory fragment, so the stored `page.ydoc`
|
||||
* keeps the OLD title — and a later body edit then reverts the rename (loads the
|
||||
* OLD fragment, sees it differs from the column, overwrites the column back to
|
||||
* OLD). Writing the ydoc here keeps the persisted fragment consistent with the
|
||||
* column so the rename survives.
|
||||
*
|
||||
* Broadcast-safe / no double broadcast: this carries no `treeUpdate`, so the
|
||||
* tree WS + redis listeners (which gate on `treeUpdate`) do NOT re-broadcast the
|
||||
* rename — only PageService.update's own PAGE_UPDATED does. The only extra
|
||||
* side-effect is an idempotent search reindex.
|
||||
*
|
||||
* Idempotent and lock-free, so it is safe whether or not a live editor is
|
||||
* connected: Yjs state is cumulative, so a concurrent onStoreDocument simply
|
||||
* persists a superset of this state later.
|
||||
*/
|
||||
async persistTitleFragmentYdoc(
|
||||
pageId: string,
|
||||
ydocState: Buffer,
|
||||
): Promise<void> {
|
||||
await this.pageRepo.updatePage({ ydoc: ydocState }, pageId);
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
@@ -171,7 +336,34 @@ export class PersistenceExtension implements Extension {
|
||||
this.logger.warn('jsonToText' + err?.['message']);
|
||||
}
|
||||
|
||||
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
|
||||
// (the collaborative title-editor contract with the client). Extract it
|
||||
// defensively: a malformed title fragment must NOT crash the document store.
|
||||
// `hasTitleFragment` distinguishes "the doc actually carries a title
|
||||
// fragment" from "legacy doc with no title fragment" — only the former may
|
||||
// write page.title, so a legacy doc never clobbers the column with ''.
|
||||
let titleText = '';
|
||||
let hasTitleFragment = false;
|
||||
try {
|
||||
const titleFrag = document.get('title', Y.XmlFragment);
|
||||
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
|
||||
if (hasTitleFragment) {
|
||||
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
|
||||
titleText = titleJson ? jsonToText(titleJson).trim() : '';
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('title extraction: ' + err?.['message']);
|
||||
hasTitleFragment = false;
|
||||
}
|
||||
|
||||
let page: Page = null;
|
||||
// Tracks whether the BODY ('default') changed in this store. The heavy
|
||||
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
|
||||
// gated on this so a title-only change does not trigger them.
|
||||
let bodyChanged = false;
|
||||
// Tracks a successful title-only persist so the post-tx contributor folding
|
||||
// (collabHistory.addContributors) runs for the title-only case too.
|
||||
let titleOnlyPersisted = false;
|
||||
const editingUserIds = this.consumeContributors(documentName);
|
||||
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
|
||||
// if the current writer is the agent (covers a store with no prior onChange
|
||||
@@ -205,11 +397,80 @@ export class PersistenceExtension implements Extension {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeepStrictEqual(tiptapJson, page.content)) {
|
||||
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
|
||||
// Only a populated 'title' fragment may update page.title; compare
|
||||
// against the current column value (treat null as '').
|
||||
//
|
||||
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
|
||||
// can momentarily initialize the 'title' fragment as an EMPTY heading
|
||||
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
|
||||
// BEFORE the server's real-title seed has synced. Writing that '' would
|
||||
// silently wipe a non-empty page.title to "untitled". A wiki page is
|
||||
// never legitimately retitled to empty via this path, so we treat an
|
||||
// empty extracted title as "not authoritative" and never persist it.
|
||||
// The `titleText.length > 0` clause makes this guard apply to BOTH the
|
||||
// title-only branch and the body+title branch below.
|
||||
//
|
||||
// DELIBERATE: this intentionally makes it impossible to retitle a page
|
||||
// to EMPTY via the collab path — a wiki page is never legitimately
|
||||
// empty-titled. If a non-empty-title rule ever needs relaxing or
|
||||
// enforcing differently, the REST UpdatePageDto is the place to validate
|
||||
// the title, not this collab guard.
|
||||
const titleChanged =
|
||||
hasTitleFragment &&
|
||||
titleText.length > 0 &&
|
||||
titleText !== (page.title ?? '');
|
||||
|
||||
// No-op fast path: neither body nor title changed.
|
||||
if (!bodyChanged && !titleChanged) {
|
||||
page = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Title-only change: the body is unchanged, so skip the heavy body
|
||||
// history/contributor logic and persist just the new title and the
|
||||
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
|
||||
// used to drop this case entirely, losing the title change.
|
||||
if (!bodyChanged) {
|
||||
// Fold the window's editing users into contributors the same way the
|
||||
// body branch does, so a user who edited ONLY the title is not dropped
|
||||
// from page.contributorIds.
|
||||
const contributorIds = Array.from(
|
||||
new Set([
|
||||
...(page.contributorIds || []),
|
||||
...editingUserIds,
|
||||
page.creatorId,
|
||||
]),
|
||||
);
|
||||
await this.pageRepo.updatePage(
|
||||
{
|
||||
title: titleText,
|
||||
ydoc: ydocState,
|
||||
lastUpdatedById: context.user.id,
|
||||
contributorIds,
|
||||
// A title-only change is not a body-authorship transition; leave
|
||||
// lastUpdatedSource/aiChatId untouched so the user->agent history
|
||||
// boundary in the body branch is not bypassed.
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||
{
|
||||
treeUpdate: {
|
||||
id: pageId,
|
||||
slugId: page.slugId,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
title: titleText,
|
||||
},
|
||||
},
|
||||
);
|
||||
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
titleOnlyPersisted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let contributorIds = undefined;
|
||||
try {
|
||||
const existingContributors = page.contributorIds || [];
|
||||
@@ -227,29 +488,22 @@ export class PersistenceExtension implements Extension {
|
||||
// Approach A — boundary snapshot before the agent's first edit.
|
||||
// When this store is the agent's and the page's currently persisted
|
||||
// state was authored by a human, pin that human state as its own
|
||||
// history version BEFORE the agent overwrites it. `page` still holds
|
||||
// the OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is
|
||||
// snapshotted later by the debounced PAGE_HISTORY job ('agent'). Skip
|
||||
// if the prior state is already agent-authored (boundary already
|
||||
// pinned on the user->agent transition), if the page is effectively
|
||||
// empty, or if the latest existing snapshot already equals this human
|
||||
// state (avoid duplicates).
|
||||
if (
|
||||
lastUpdatedSource === 'agent' &&
|
||||
page.lastUpdatedSource !== 'agent'
|
||||
) {
|
||||
// history version BEFORE the agent overwrites it. `page` still holds the
|
||||
// OLD content/provenance here, so saveHistory(page) captures the
|
||||
// pre-agent state tagged 'user'. The agent's new content is snapshotted
|
||||
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
|
||||
// state is already agent-authored (boundary already pinned on the
|
||||
// user->agent transition), if the page is effectively empty, or if the
|
||||
// latest existing snapshot already equals this human state (avoid
|
||||
// duplicates).
|
||||
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
pageId,
|
||||
{ includeContent: true, trx },
|
||||
);
|
||||
const humanBaselineMissing =
|
||||
!lastHistory ||
|
||||
!isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (
|
||||
!isEmptyParagraphDoc(page.content as any) &&
|
||||
humanBaselineMissing
|
||||
) {
|
||||
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
|
||||
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
|
||||
await this.pageHistoryRepo.saveHistory(page, {
|
||||
contributorIds: page.contributorIds ?? undefined,
|
||||
trx,
|
||||
@@ -267,9 +521,27 @@ export class PersistenceExtension implements Extension {
|
||||
lastUpdatedSource,
|
||||
lastUpdatedAiChatId: context?.aiChatId ?? null,
|
||||
contributorIds: contributorIds,
|
||||
// Persist the title in the SAME transaction when the title fragment
|
||||
// changed alongside the body.
|
||||
...(titleChanged ? { title: titleText } : {}),
|
||||
},
|
||||
pageId,
|
||||
trx,
|
||||
// Mirror PageService.update's tree snapshot so a collaborative rename
|
||||
// propagates to other users' sidebar/breadcrumbs like the REST rename.
|
||||
// Only attach when the title actually changed; a body-only save must
|
||||
// not trigger a tree broadcast.
|
||||
titleChanged
|
||||
? {
|
||||
treeUpdate: {
|
||||
id: pageId,
|
||||
slugId: page.slugId,
|
||||
spaceId: page.spaceId,
|
||||
parentPageId: page.parentPageId ?? null,
|
||||
title: titleText,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
|
||||
@@ -290,6 +562,8 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
}
|
||||
|
||||
// `page` is truthy whenever anything was persisted (body OR title-only), so
|
||||
// the page.updated broadcast fires for a title-only change too.
|
||||
if (page) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
@@ -307,11 +581,20 @@ export class PersistenceExtension implements Extension {
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Record the window's editing users in collab history for a title-only
|
||||
// change too (the body branch does this below, gated on bodyChanged).
|
||||
if (page && titleOnlyPersisted) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
}
|
||||
|
||||
// Body-only side-effects: skip them for a title-only change (body unchanged).
|
||||
if (page && bodyChanged) {
|
||||
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
if (page && bodyChanged) {
|
||||
await this.collabHistory.addContributors(pageId, editingUserIds);
|
||||
|
||||
const mentions = extractMentions(tiptapJson);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
|
||||
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||
import {
|
||||
PageEvent,
|
||||
TreeUpdateSnapshot,
|
||||
} from '../../database/listeners/page.listener';
|
||||
|
||||
const treeUpdate: TreeUpdateSnapshot = {
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
title: 'Renamed',
|
||||
icon: '🚀',
|
||||
};
|
||||
|
||||
describe('PageTreeBridgePublisher', () => {
|
||||
let publisher: PageTreeBridgePublisher;
|
||||
let redis: { publish: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
redis = { publish: jest.fn().mockResolvedValue(1) };
|
||||
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PageTreeBridgePublisher,
|
||||
{ provide: RedisService, useValue: redisService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
|
||||
});
|
||||
|
||||
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
treeUpdate,
|
||||
};
|
||||
|
||||
await publisher.onPageUpdated(event);
|
||||
|
||||
expect(redis.publish).toHaveBeenCalledTimes(1);
|
||||
expect(redis.publish).toHaveBeenCalledWith(
|
||||
COLLAB_TREE_UPDATE_CHANNEL,
|
||||
JSON.stringify(treeUpdate),
|
||||
);
|
||||
});
|
||||
|
||||
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await publisher.onPageUpdated(event);
|
||||
|
||||
expect(redis.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a publish rejection is caught (no throw)', async () => {
|
||||
redis.publish.mockRejectedValueOnce(new Error('redis down'));
|
||||
const errorSpy = jest
|
||||
.spyOn(publisher['logger'], 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
treeUpdate,
|
||||
};
|
||||
|
||||
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { EventName } from '../../common/events/event.contants';
|
||||
import { PageEvent } from '../../database/listeners/page.listener';
|
||||
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
|
||||
|
||||
/**
|
||||
* Collab-process half of the cross-process tree-update bridge.
|
||||
*
|
||||
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
|
||||
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
|
||||
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
|
||||
* is no listener in this process to broadcast it — the live tree update would be
|
||||
* lost for 2-process (COLLAB_URL set) deployments.
|
||||
*
|
||||
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
|
||||
* Redis pub/sub channel to the API process, which re-broadcasts it via
|
||||
* `WsTreeService` (the single broadcast authority).
|
||||
*
|
||||
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
|
||||
* API process (where `PageWsListener` already broadcasts the same event locally).
|
||||
* That module placement is what prevents a double broadcast. In single-process
|
||||
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PageTreeBridgePublisher {
|
||||
private readonly logger = new Logger(PageTreeBridgePublisher.name);
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_UPDATED)
|
||||
async onPageUpdated(event: PageEvent): Promise<void> {
|
||||
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
|
||||
// Content-only saves leave `treeUpdate` undefined and are ignored.
|
||||
if (!event.treeUpdate) return;
|
||||
|
||||
try {
|
||||
await this.redis.publish(
|
||||
COLLAB_TREE_UPDATE_CHANNEL,
|
||||
JSON.stringify(event.treeUpdate),
|
||||
);
|
||||
} catch (err) {
|
||||
// A Redis publish failure must not break the store path.
|
||||
this.logger.error(
|
||||
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { CaslModule } from '../../core/casl/casl.module';
|
||||
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -54,6 +55,6 @@ import KeyvRedis from '@keyv/redis';
|
||||
? [CollaborationController]
|
||||
: []),
|
||||
],
|
||||
providers: [AppService],
|
||||
providers: [AppService, PageTreeBridgePublisher],
|
||||
})
|
||||
export class CollabAppModule {}
|
||||
|
||||
187
apps/server/src/collaboration/title-rename-durability.spec.ts
Normal file
187
apps/server/src/collaboration/title-rename-durability.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as Y from 'yjs';
|
||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||
import { CollaborationGateway } from './collaboration.gateway';
|
||||
import { PersistenceExtension } from './extensions/persistence.extension';
|
||||
import {
|
||||
buildTitleSeedYdoc,
|
||||
jsonToText,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
|
||||
/**
|
||||
* F1 (variant C) — rename durability for a page with an already-persisted Yjs
|
||||
* 'title' fragment and NO live editor (the REST/MCP/agent rename path).
|
||||
*
|
||||
* The bug: PageService.update writes the NEW title to the `page.title` COLUMN,
|
||||
* then calls gateway.writePageTitle, which loads the page's ydoc (fragment =
|
||||
* OLD) and overwrites it to NEW in memory. On disconnect, onStoreDocument sees
|
||||
* titleText(NEW) === column(NEW) → no-op fast-path → it does NOT persist the
|
||||
* in-memory fragment. So `page.ydoc` keeps the OLD title, and a LATER body edit
|
||||
* loads the OLD fragment, sees it differs from the column, and silently reverts
|
||||
* the column back to OLD.
|
||||
*
|
||||
* The fix: writePageTitle persists the 'title' fragment to `page.ydoc` DIRECTLY
|
||||
* (via PersistenceExtension.persistTitleFragmentYdoc) after the transact, so the
|
||||
* persisted fragment and the column stay consistent.
|
||||
*
|
||||
* This test drives the REAL writePageTitle + the REAL onStoreDocument against an
|
||||
* in-memory page row, so it FAILS on the pre-fix no-op behaviour and PASSES after.
|
||||
*/
|
||||
|
||||
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const USER_ID = 'user-1';
|
||||
const OLD_TITLE = 'Old Title';
|
||||
const NEW_TITLE = 'Renamed Title';
|
||||
|
||||
const bodyJson = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
// Build the initial persisted ydoc carrying BOTH a 'title' fragment and a body.
|
||||
const makeInitialYdoc = (title: string, body: any): Buffer => {
|
||||
const doc = new Y.Doc();
|
||||
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
|
||||
Y.applyUpdate(
|
||||
doc,
|
||||
Y.encodeStateAsUpdate(TiptapTransformer.toYdoc(body, 'default', tiptapExtensions)),
|
||||
);
|
||||
return Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
};
|
||||
|
||||
// Load a doc from a persisted buffer (mirrors openDirectConnection loading from
|
||||
// persistence when no editor is connected). hocuspocus augments the live doc
|
||||
// with broadcastStateless(); a bare Y.Doc lacks it, so stub it.
|
||||
const loadDoc = (buf: Buffer): Y.Doc => {
|
||||
const doc = new Y.Doc();
|
||||
if (buf) Y.applyUpdate(doc, new Uint8Array(buf));
|
||||
(doc as any).broadcastStateless = jest.fn();
|
||||
return doc;
|
||||
};
|
||||
|
||||
// Read the 'title' fragment text from a persisted buffer.
|
||||
const readTitle = (buf: Buffer): string => {
|
||||
const doc = loadDoc(buf);
|
||||
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||
};
|
||||
|
||||
describe('rename durability (F1 variant C): persisted title fragment survives a body edit', () => {
|
||||
it('persists the renamed title into page.ydoc so a later body edit does not revert it', async () => {
|
||||
// In-memory page row = the DB.
|
||||
const row: any = {
|
||||
id: PAGE_ID,
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
creatorId: 'creator-1',
|
||||
contributorIds: ['creator-1'],
|
||||
createdAt: new Date('2020-01-01T00:00:00Z'),
|
||||
lastUpdatedSource: 'user',
|
||||
title: OLD_TITLE,
|
||||
// content column mirrors the normalized body in the ydoc.
|
||||
content: TiptapTransformer.fromYdoc(
|
||||
loadDoc(makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1'))),
|
||||
'default',
|
||||
),
|
||||
ydoc: makeInitialYdoc(OLD_TITLE, bodyJson('BODY V1')),
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({ ...row })),
|
||||
updatePage: jest.fn(async (data: any, _pageId?: string) => {
|
||||
Object.assign(row, data, { updatedAt: new Date() });
|
||||
}),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
saveHistory: jest.fn().mockResolvedValue(undefined),
|
||||
findPageLastHistory: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
const noopQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
|
||||
const transclusionService = {
|
||||
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageReferences: jest.fn().mockResolvedValue(undefined),
|
||||
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// db whose transaction().execute(fn) runs fn with a trx stub (drives the
|
||||
// real executeTx helper without a database).
|
||||
const db = {
|
||||
transaction: () => ({
|
||||
execute: (fn: (trx: any) => Promise<any>) => fn({ __trx: true }),
|
||||
}),
|
||||
};
|
||||
|
||||
const ext = new PersistenceExtension(
|
||||
pageRepo as any,
|
||||
pageHistoryRepo as any,
|
||||
db as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
noopQueue as any,
|
||||
collabHistory as any,
|
||||
transclusionService as any,
|
||||
);
|
||||
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(ext['logger'], 'error').mockImplementation(() => undefined);
|
||||
|
||||
const documentName = `page.${PAGE_ID}`;
|
||||
// Fake hocuspocus: openDirectConnection loads a doc from the CURRENT persisted
|
||||
// ydoc (no live editor) and, on disconnect, runs the real onStoreDocument —
|
||||
// exactly the no-live-editor unload path.
|
||||
const fakeHocuspocus = {
|
||||
openDirectConnection: jest.fn(async (name: string, context: any) => {
|
||||
const liveDoc = loadDoc(row.ydoc);
|
||||
return {
|
||||
transact: async (fn: (doc: Y.Doc) => void) => fn(liveDoc),
|
||||
disconnect: async () => {
|
||||
await ext.onStoreDocument({
|
||||
documentName: name,
|
||||
document: liveDoc,
|
||||
context,
|
||||
} as any);
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const gateway: CollaborationGateway = Object.create(
|
||||
CollaborationGateway.prototype,
|
||||
);
|
||||
(gateway as any).hocuspocus = fakeHocuspocus;
|
||||
(gateway as any).persistenceExtension = ext;
|
||||
|
||||
// --- REST/service rename (no live editor) ---
|
||||
// 1) PageService.update writes the NEW title to the column.
|
||||
await pageRepo.updatePage({ title: NEW_TITLE }, PAGE_ID);
|
||||
// 2) PageService.update syncs the Yjs 'title' fragment.
|
||||
await gateway.writePageTitle(PAGE_ID, NEW_TITLE, {
|
||||
user: { id: USER_ID } as any,
|
||||
});
|
||||
|
||||
// Reload the persisted ydoc: the 'title' fragment must now be NEW.
|
||||
// (Pre-fix this is still OLD — writePageTitle did not persist the fragment.)
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
|
||||
// --- a later body edit must NOT revert the title ---
|
||||
const editDoc = loadDoc(row.ydoc);
|
||||
const frag = editDoc.getXmlFragment('default');
|
||||
const p = new Y.XmlElement('paragraph');
|
||||
const t = new Y.XmlText();
|
||||
t.insert(0, 'appended');
|
||||
p.insert(0, [t]);
|
||||
frag.insert(frag.length, [p]);
|
||||
|
||||
await ext.onStoreDocument({
|
||||
documentName,
|
||||
document: editDoc,
|
||||
context: { user: { id: USER_ID } },
|
||||
} as any);
|
||||
|
||||
// The body edit was persisted, and the title stayed NEW in BOTH the column
|
||||
// and the persisted ydoc fragment (pre-fix the column reverts to OLD).
|
||||
expect(row.title).toBe(NEW_TITLE);
|
||||
expect(readTitle(row.ydoc)).toBe(NEW_TITLE);
|
||||
});
|
||||
});
|
||||
@@ -1,492 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
AiChatRunService,
|
||||
RunAlreadyActiveError,
|
||||
ONE_ACTIVE_RUN_PER_CHAT_INDEX,
|
||||
mapTurnStatusToRun,
|
||||
} from './ai-chat-run.service';
|
||||
|
||||
/** Shape a Postgres unique-violation the way the postgres.js driver surfaces it:
|
||||
* SQLSTATE 23505 + the offending index in `constraint_name`. */
|
||||
function uniqueViolation(constraintName: string): Error & {
|
||||
code: string;
|
||||
constraint_name: string;
|
||||
} {
|
||||
return Object.assign(
|
||||
new Error('duplicate key value violates unique constraint'),
|
||||
{
|
||||
code: '23505',
|
||||
constraint_name: constraintName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit coverage for the #184 phase-1 run lifecycle (AiChatRunService) with a
|
||||
* hand-rolled mock repo — no Nest graph, no DB. The invariant under test is the
|
||||
* one that makes a run "autonomous": a run keeps going when its SUBSCRIBER (the
|
||||
* browser) detaches, and ONLY an explicit stop aborts it. We assert that at the
|
||||
* abort-signal level (the signal the agent loop actually consumes).
|
||||
*/
|
||||
|
||||
/** Minimal EnvironmentService stub. Single-instance (CLOUD unset) by default. */
|
||||
function makeEnv(isCloud = false) {
|
||||
return { isCloud: () => isCloud };
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
||||
return {
|
||||
insert: jest.fn(async (v: any) => ({
|
||||
id: 'run-1',
|
||||
status: v.status ?? 'running',
|
||||
chatId: v.chatId,
|
||||
workspaceId: v.workspaceId,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||
findActiveByChat: jest.fn(async () => undefined),
|
||||
findLatestByChat: jest.fn(async () => undefined),
|
||||
findById: jest.fn(async () => undefined),
|
||||
sweepRunning: jest.fn(async () => 0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapTurnStatusToRun', () => {
|
||||
it('maps the turn terminal status to the run terminal status', () => {
|
||||
expect(mapTurnStatusToRun('completed')).toBe('succeeded');
|
||||
expect(mapTurnStatusToRun('error')).toBe('failed');
|
||||
expect(mapTurnStatusToRun('aborted')).toBe('aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChatRunService.onModuleInit (startup sweep)', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('calls sweepRunning and resolves; logs when > 0', async () => {
|
||||
const repo = makeRepo({ sweepRunning: jest.fn(async () => 2) });
|
||||
const logSpy = jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(logSpy.mock.calls[0][0])).toContain('2');
|
||||
});
|
||||
|
||||
it('a sweep failure is swallowed (never blocks startup)', async () => {
|
||||
const repo = makeRepo({
|
||||
sweepRunning: jest.fn(async () => {
|
||||
throw new Error('db down');
|
||||
}),
|
||||
});
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||
// The first warn is the sweep failure (the multi-instance warn never fires
|
||||
// single-instance), so the message is the db error.
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db down');
|
||||
});
|
||||
|
||||
it('F1 (DECISION C): the boot sweep is UNCONDITIONAL — sweepRunning is called with NO staleness window, so a fresh running run (updatedAt = now) is settled, not skipped', async () => {
|
||||
// The bug: a fast restart (deploy/OOM within minutes of the last step) left a
|
||||
// run stuck 'running' under the old 10-min window, 409ing every later turn in
|
||||
// the chat. The fix settles ALL pending|running on boot. We assert the service
|
||||
// invokes sweepRunning with no `staleMs` (the unconditional path); the repo's
|
||||
// own spec proves no-window => no updatedAt filter.
|
||||
const repo = makeRepo({ sweepRunning: jest.fn(async () => 1) });
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.onModuleInit();
|
||||
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||
const callArgs = repo.sweepRunning.mock.calls[0] as unknown[];
|
||||
const firstArg = callArgs[0] as { staleMs?: number } | undefined;
|
||||
// Either no opts at all, or opts without a staleMs window => unconditional.
|
||||
expect(firstArg?.staleMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('F2 (DECISION A): warns at startup that autonomousRuns is single-instance-only when a horizontally-scaled deployment (CLOUD) is detected', async () => {
|
||||
const repo = makeRepo();
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv(true) as never);
|
||||
await svc.onModuleInit();
|
||||
const warned = warnSpy.mock.calls.some((c) =>
|
||||
/single-instance-only/i.test(String(c[0])),
|
||||
);
|
||||
expect(warned).toBe(true);
|
||||
});
|
||||
|
||||
it('F2: does NOT warn about multi-instance on a single-instance (CLOUD unset) deployment', async () => {
|
||||
const repo = makeRepo();
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv(false) as never);
|
||||
await svc.onModuleInit();
|
||||
const warned = warnSpy.mock.calls.some((c) =>
|
||||
/single-instance-only/i.test(String(c[0])),
|
||||
);
|
||||
expect(warned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChatRunService run lifecycle', () => {
|
||||
it('beginRun inserts a running row and registers a live abort controller', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repo.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
createdBy: 'user-1',
|
||||
status: 'running',
|
||||
trigger: 'user',
|
||||
}),
|
||||
);
|
||||
expect(handle.runId).toBe('run-1');
|
||||
expect(handle.signal.aborted).toBe(false);
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('beginRun REJECTS the racer: a 23505 on the one-active-per-chat index throws RunAlreadyActiveError (not swallowed) and registers no controller', async () => {
|
||||
// The race: the controller's cheap pre-check passed for BOTH concurrent
|
||||
// turns, so the loser's INSERT hits the partial unique index. That rejection
|
||||
// is the authoritative gate — it must surface, not be swallowed into an
|
||||
// untracked turn.
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async () => {
|
||||
throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(RunAlreadyActiveError);
|
||||
// No controller leaked for a rejected start.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('beginRun does NOT mask an unrelated unique violation as already-active', async () => {
|
||||
// A 23505 on some OTHER constraint is a real bug, not the race — it must
|
||||
// propagate unchanged so it is never silently treated as "already active".
|
||||
const other = uniqueViolation('ai_chat_runs_pkey');
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async () => {
|
||||
throw other;
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
).rejects.toBe(other);
|
||||
});
|
||||
|
||||
it('beginRun propagates a non-unique insert failure unchanged', async () => {
|
||||
const boom = new Error('connection reset');
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async () => {
|
||||
throw boom;
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
it('two concurrent begins on one chat: exactly one wins, the other is rejected as already-active', async () => {
|
||||
// Integration-style: model the DB partial unique index with a one-shot slot.
|
||||
// The first insert claims it; the second hits a 23505 on the active index.
|
||||
let slotTaken = false;
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async (v: any) => {
|
||||
if (slotTaken) throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||
slotTaken = true;
|
||||
return { id: 'run-win', status: v.status, chatId: v.chatId };
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const results = await Promise.allSettled([
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
]);
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
const rejected = results.filter((r) => r.status === 'rejected');
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
RunAlreadyActiveError,
|
||||
);
|
||||
// Exactly the winner is locally active.
|
||||
expect(svc.isLocallyActive('run-win')).toBe(true);
|
||||
});
|
||||
|
||||
it('a SUBSCRIBER detaching does NOT abort the run (only an explicit stop does)', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
// Model a browser disconnect: nothing in the run service is told to stop.
|
||||
// The signal the agent loop consumes must stay un-aborted and the run stays
|
||||
// locally active — i.e. it keeps running server-side.
|
||||
expect(handle.signal.aborted).toBe(false);
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
// markStopRequested was never called by a mere detach.
|
||||
expect(repo.markStopRequested).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requestStop aborts the live controller, marks the row, and reports true', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const aborted = jest.fn();
|
||||
handle.signal.addEventListener('abort', aborted);
|
||||
|
||||
const result = await svc.requestStop('run-1', 'ws-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(handle.signal.aborted).toBe(true);
|
||||
expect(aborted).toHaveBeenCalledTimes(1);
|
||||
expect(repo.markStopRequested).toHaveBeenCalledWith('run-1', 'ws-1');
|
||||
});
|
||||
|
||||
it('requestStop on a run this replica does NOT hold still marks the row (true)', async () => {
|
||||
// e.g. after a restart, or a sibling replica owns the controller. The row is
|
||||
// marked so the owning replica/sweep settles it; we report a stop took effect.
|
||||
const repo = makeRepo({
|
||||
markStopRequested: jest.fn(async () => ({ id: 'run-9' })),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const result = await svc.requestStop('run-9', 'ws-1');
|
||||
expect(result).toBe(true);
|
||||
expect(svc.isLocallyActive('run-9')).toBe(false);
|
||||
});
|
||||
|
||||
it('requestStop on an already-settled run (nothing active) reports false', async () => {
|
||||
const repo = makeRepo({
|
||||
markStopRequested: jest.fn(async () => undefined),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const result = await svc.requestStop('run-done', 'ws-1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('finalizeRun settles the row to the mapped status with finishedAt and drops the in-memory entry', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
error: 'provider blew up',
|
||||
finishedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('finalizeRun is IDEMPOTENT: a second settle no-ops (single terminal write)', async () => {
|
||||
// The #184 review fix: AiChatService.stream wraps the turn in a safety-net
|
||||
// catch that settles a failed turn AND streamText's terminal callback may
|
||||
// also settle — both routes call finalizeRun. Only the FIRST may write the
|
||||
// terminal row; the second must no-op so a late settle can never clobber the
|
||||
// real terminal status or double-write the row.
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'first');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('CONCURRENCY: two simultaneous finalizeRun on the same run write the terminal row EXACTLY ONCE (the 2nd caller exits synchronously at the atomic claim)', async () => {
|
||||
// The CRITICAL race: AiChatService.stream's safety-net catch settles the turn
|
||||
// to 'error' while a streamText terminal callback also settles it — both call
|
||||
// finalizeRun for the SAME runId. The once-gate must close ATOMICALLY: a
|
||||
// `settled.has` check alone is read BEFORE the awaited UPDATE, so both callers
|
||||
// would pass it and BOTH write the row (last-write-wins clobber + double
|
||||
// write). The fix claims the run with a SYNCHRONOUS `active.delete` before any
|
||||
// await, so the second caller returns in the same tick, before the UPDATE.
|
||||
//
|
||||
// We force the two calls to overlap by making `update` return a promise we
|
||||
// resolve only AFTER both finalizeRun calls have run their synchronous bodies.
|
||||
let resolveUpdate!: (v: unknown) => void;
|
||||
const updateGate = new Promise((res) => {
|
||||
resolveUpdate = res;
|
||||
});
|
||||
const update = jest.fn(() => updateGate);
|
||||
const repo = makeRepo({ update });
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
// Fire both before the (pending) update resolves. The first synchronously
|
||||
// claims the entry (active.delete) and awaits update; the second, started in
|
||||
// the same macrotask, finds the entry already gone and returns at the claim
|
||||
// WITHOUT ever calling update.
|
||||
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||
|
||||
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Let the single in-flight update land; both calls resolve cleanly.
|
||||
resolveUpdate({ id: 'run-1' });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||
// 'error' settle never wrote, so it could not clobber the real status.
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('F6: a TRANSIENT terminal-write failure is ridden out by the bounded retry — the run is settled, not stranded', async () => {
|
||||
// The bug: finalizeRun used to DROP the in-memory entry BEFORE the terminal
|
||||
// UPDATE, then only warn-log a failure. A single transient blip (pool
|
||||
// exhaustion / deadlock / connection hiccup) on that PK UPDATE left the row
|
||||
// 'running' with nothing left to recover it -> every later turn in that chat
|
||||
// 409s until a restart. The fix updates FIRST and retries.
|
||||
let calls = 0;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('deadlock detected');
|
||||
return { id: 'run-1' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
|
||||
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
||||
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||
let healthy = false;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
if (!healthy) throw new Error('pool exhausted');
|
||||
return { id: 'run-1' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||
// blip — distinct from the per-attempt warns.
|
||||
const gaveUp = errorSpy.mock.calls.some(
|
||||
(c) =>
|
||||
/NON-TERMINAL/.test(String(c[0])) &&
|
||||
/run-1/.test(String(c[0])) &&
|
||||
/chat-1/.test(String(c[0])),
|
||||
);
|
||||
expect(gaveUp).toBe(true);
|
||||
|
||||
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||
healthy = true;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
|
||||
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||
// written), so a double-settle can never clobber the real status.
|
||||
const callsBefore = repo.update.mock.calls.length;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||
});
|
||||
|
||||
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
throw new Error('transient');
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(svc.recordStep('run-1', 'ws-1', 3)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
svc.linkAssistantMessage('run-1', 'ws-1', 'msg-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,426 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||
import { AiChatRun } from '@docmost/db/types/entity.types';
|
||||
import { isUniqueViolation, violatedConstraint } from '@docmost/db/utils';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/** Name of the partial unique index enforcing "one active run per chat" (see the
|
||||
* ai_chat_runs migration). A 23505 on THIS constraint is the race-safe signal
|
||||
* that a concurrent turn already owns the chat — distinct from any other unique
|
||||
* collision, which must NOT be silently treated as "already active". */
|
||||
export const ONE_ACTIVE_RUN_PER_CHAT_INDEX = 'ai_chat_runs_one_active_per_chat';
|
||||
|
||||
/**
|
||||
* Thrown by {@link AiChatRunService.beginRun} when the run-row INSERT loses the
|
||||
* race for a chat's single active slot (the partial unique index rejects it with
|
||||
* a 23505). This is the AUTHORITATIVE concurrency gate: the controller's cheap
|
||||
* pre-check is only a fast-path, and a request that slips past it must NOT run
|
||||
* untracked. The caller (AiChatService.stream) translates this into a 409 and
|
||||
* aborts the turn BEFORE any AI/provider call.
|
||||
*/
|
||||
export class RunAlreadyActiveError extends Error {
|
||||
constructor(public readonly chatId: string) {
|
||||
super(`An agent run is already in progress for chat ${chatId}`);
|
||||
this.name = 'RunAlreadyActiveError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal status of a TURN (the #183 assistant-row lifecycle) maps onto the
|
||||
* terminal status of a RUN (#184). A turn that completed -> the run succeeded; a
|
||||
* turn that errored -> the run failed; a turn aborted (explicit user stop) -> the
|
||||
* run aborted. Pure + unit-testable.
|
||||
*/
|
||||
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||
|
||||
export function mapTurnStatusToRun(
|
||||
status: TurnTerminalStatus,
|
||||
): RunTerminalStatus {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'succeeded';
|
||||
case 'error':
|
||||
return 'failed';
|
||||
case 'aborted':
|
||||
return 'aborted';
|
||||
}
|
||||
}
|
||||
|
||||
/** An in-flight run held in process memory: its AbortController is the ONLY thing
|
||||
* that can stop the turn (an explicit user stop), independent of the browser
|
||||
* socket. A mere disconnect never touches it, so the run keeps going. */
|
||||
interface ActiveRun {
|
||||
controller: AbortController;
|
||||
chatId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/** The live handle the streaming path drives a run through (returned by
|
||||
* {@link AiChatRunService.beginRun}). The `signal` governs the agent loop's
|
||||
* abort — wired to the run, NOT to the HTTP socket. */
|
||||
export interface RunHandle {
|
||||
runId: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* AiChatRunService (#184 phase 1) — owns the agent RUN as a first-class,
|
||||
* server-side lifecycle object detached from the HTTP request / browser window.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - create a run row when a turn starts (pending -> running) and register an
|
||||
* in-memory AbortController for it (the explicit-stop lever);
|
||||
* - finalize the run row (succeeded / failed / aborted) and unregister it;
|
||||
* - service an EXPLICIT user stop (`requestStop`) — the ONLY thing that aborts a
|
||||
* run; a browser disconnect deliberately does NOT;
|
||||
* - crash-recovery sweep of dangling runs on startup.
|
||||
*
|
||||
* The agent loop itself still runs in AiChatService.stream (reusing #183's
|
||||
* step-granular durable write path, `consumeStream` already drains it independent
|
||||
* of the socket); this service only wraps it in a durable lifecycle and an
|
||||
* abort handle that outlives the subscriber.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiChatRunService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiChatRunService.name);
|
||||
|
||||
// runId -> ActiveRun. Process-local on purpose (phase 1 is single-process /
|
||||
// in-memory transport; a cross-process BullMQ runner + Redis stop-signal is
|
||||
// deferred to phase 2). A stop for a runId not in this map (e.g. after a
|
||||
// restart) still records `stop_requested_at` on the row.
|
||||
private readonly active = new Map<string, ActiveRun>();
|
||||
|
||||
// runIds whose TERMINAL row write has SUCCEEDED — the idempotency once-gate
|
||||
// (F6). A finalize must short-circuit only AFTER the terminal write has landed,
|
||||
// NOT merely after the in-memory entry was dropped: a transient UPDATE failure
|
||||
// has to stay retryable, so "already settled" means "row already terminal", not
|
||||
// "entry already gone". Grows by one short UUID per finished run over process
|
||||
// uptime — negligible in phase 1's single process.
|
||||
private readonly settled = new Set<string>();
|
||||
|
||||
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||
// brief connection blip). Riding out that blip in-place matters because the
|
||||
// dominant success path (streamText onFinish) settles exactly ONCE — if that
|
||||
// write is dropped and never retried, the row is stranded 'running' and the
|
||||
// one-active-run gate 409s every future turn in the chat until a restart (no
|
||||
// periodic sweep in phase 1).
|
||||
private static readonly FINALIZE_MAX_ATTEMPTS = 3;
|
||||
private static readonly FINALIZE_RETRY_BASE_MS = 50;
|
||||
|
||||
constructor(
|
||||
private readonly runRepo: AiChatRunRepo,
|
||||
private readonly environment: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Crash-recovery sweep on server start: settle EVERY run still left
|
||||
* pending/running to 'aborted' (F1 / DECISION C). The boot sweep is
|
||||
* UNCONDITIONAL — no staleness window — because phase 1 is single-process: on a
|
||||
* fresh boot any pending|running run is definitionally hung (no live runner owns
|
||||
* it), so even a fast restart (deploy/OOM within minutes of the last step) can
|
||||
* no longer leave a run stuck 'running' forever (which would make the
|
||||
* one-active-run gate 409 every future turn in that chat). The staleness window
|
||||
* is reintroduced only for the phase-2 multi-instance timer sweep, where a
|
||||
* booting replica must not abort a run another replica is actively executing.
|
||||
* Best-effort — a sweep failure is logged but MUST NOT block startup (mirrors
|
||||
* AiChatService.onModuleInit for #183).
|
||||
*/
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.warnIfMultiInstance();
|
||||
try {
|
||||
// No `staleMs`: unconditional boot sweep (F1). See AiChatRunRepo.sweepRunning.
|
||||
const swept = await this.runRepo.sweepRunning();
|
||||
if (swept > 0) {
|
||||
this.logger.log(
|
||||
`Startup sweep: marked ${swept} dangling agent run(s) as 'aborted'.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Startup sweep of dangling runs failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F2 (DECISION A): autonomous runs are SINGLE-INSTANCE-ONLY in phase 1. An
|
||||
* explicit Stop, and the in-memory AbortController that backs it, are
|
||||
* process-local: a Stop only aborts the live turn if it lands on the SAME
|
||||
* replica that owns the run (it still stamps `stop_requested_at` cross-instance,
|
||||
* but nothing reads that flag during an active run yet). Cross-instance pub/sub
|
||||
* stop is phase 2. So if the deployment is horizontally scaled, warn loudly at
|
||||
* startup that a Stop may not reach a run executing on another replica.
|
||||
*
|
||||
* DETECTION: this codebase always wires the socket.io Redis adapter (REDIS_URL
|
||||
* is mandatory), so the adapter alone is NOT a horizontal-scaling signal. The
|
||||
* authoritative signal the codebase has is `CLOUD=true` (EnvironmentService
|
||||
* .isCloud()), the Docmost-cloud multi-replica deployment. We warn whenever that
|
||||
* is set, because any workspace could enable settings.ai.autonomousRuns. A
|
||||
* self-hosted operator running multiple replicas behind a load balancer is also
|
||||
* multi-instance; the deploy docs (.env.example / AGENTS.md) spell out the
|
||||
* single-instance constraint for that case.
|
||||
*/
|
||||
private warnIfMultiInstance(): void {
|
||||
if (this.environment.isCloud()) {
|
||||
this.logger.warn(
|
||||
'Autonomous agent runs (settings.ai.autonomousRuns) are SINGLE-INSTANCE-ONLY ' +
|
||||
'in phase 1: a horizontally-scaled deployment was detected (CLOUD=true). ' +
|
||||
'An explicit Stop only aborts a run executing on the same replica that owns ' +
|
||||
'it (cross-instance Stop is not yet reliable — phase 2). Run a single ' +
|
||||
'instance if you enable autonomousRuns, or keep the flag off.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a run for a turn: insert the run row (status 'running', startedAt now),
|
||||
* register a fresh AbortController for it, and return a {@link RunHandle} whose
|
||||
* `signal` the agent loop uses. The DB partial unique index guarantees at most
|
||||
* one active run per chat — a second concurrent start on the same chat REJECTS
|
||||
* at the insert (a 23505 on {@link ONE_ACTIVE_RUN_PER_CHAT_INDEX}). That
|
||||
* rejection is the AUTHORITATIVE race gate: it is surfaced as a distinct
|
||||
* {@link RunAlreadyActiveError} (NOT swallowed), so the caller turns it into a
|
||||
* 409 and never streams an untracked turn. The controller is registered AFTER a
|
||||
* successful insert so a rejected start leaks nothing.
|
||||
*/
|
||||
async beginRun(args: {
|
||||
chatId: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
trigger?: string;
|
||||
}): Promise<RunHandle> {
|
||||
let run: AiChatRun;
|
||||
try {
|
||||
run = await this.runRepo.insert({
|
||||
chatId: args.chatId,
|
||||
workspaceId: args.workspaceId,
|
||||
createdBy: args.userId,
|
||||
trigger: args.trigger ?? 'user',
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
// The race backstop: a concurrent turn already holds this chat's single
|
||||
// active slot, so the partial unique index rejected our insert. Surface a
|
||||
// distinct signal — the caller MUST reject this turn (409), not run it
|
||||
// untracked. Any OTHER error propagates unchanged.
|
||||
if (
|
||||
isUniqueViolation(err) &&
|
||||
violatedConstraint(err) === ONE_ACTIVE_RUN_PER_CHAT_INDEX
|
||||
) {
|
||||
throw new RunAlreadyActiveError(args.chatId);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.active.set(run.id, {
|
||||
controller,
|
||||
chatId: args.chatId,
|
||||
workspaceId: args.workspaceId,
|
||||
});
|
||||
return { runId: run.id, signal: controller.signal };
|
||||
}
|
||||
|
||||
/** Link the assistant message (the #183 projection) to its run. Best-effort. */
|
||||
async linkAssistantMessage(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
assistantMessageId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, { assistantMessageId });
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to link assistant message to run ${runId}: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist progress: bump the run's finished-step count. Best-effort (never
|
||||
* blocks or breaks the stream). */
|
||||
async recordStep(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
stepCount: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, { stepCount });
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to record step for run ${runId}: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||
*
|
||||
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||
* so two callers can both see `false` and both write the row (last-write-wins
|
||||
* clobbers the real terminal status, and the bounded retry only widens that
|
||||
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
||||
* check-and-clear with NO await between the gate and the entry removal: the
|
||||
* second concurrent caller finds the entry already gone and returns in the same
|
||||
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
||||
* finalizing" is thus a single atomic step.
|
||||
*
|
||||
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
||||
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
||||
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
||||
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
||||
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
||||
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
||||
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
||||
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
||||
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
||||
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
||||
* and harmless meanwhile.
|
||||
*
|
||||
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||
* double-settle collapses to a single write and a late settle can never clobber
|
||||
* the real terminal status or double-write the row.
|
||||
*/
|
||||
async finalizeRun(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
turnStatus: TurnTerminalStatus,
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||
// Already terminally written -> idempotent no-op.
|
||||
if (this.settled.has(runId)) return;
|
||||
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||
const entry = this.active.get(runId);
|
||||
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||
// the same tick, before any await — so it can never reach the UPDATE.
|
||||
if (!this.active.delete(runId)) return;
|
||||
|
||||
let lastError: unknown;
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= AiChatRunService.FINALIZE_MAX_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, {
|
||||
status: mapTurnStatusToRun(turnStatus),
|
||||
finishedAt: new Date(),
|
||||
error: error ?? null,
|
||||
});
|
||||
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||
// (claimed above); we do NOT restore it. The slot is now free.
|
||||
this.settled.add(runId);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
this.logger.warn(
|
||||
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||
);
|
||||
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||
this.logger.error(
|
||||
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||
`('running'): terminal write failed after ${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||
`boot sweep`,
|
||||
lastError,
|
||||
);
|
||||
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||
// window above is consumed at the `active.delete` claim, and the no-streamText
|
||||
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
||||
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||
// cleared on restart.
|
||||
if (entry) this.active.set(runId, entry);
|
||||
}
|
||||
|
||||
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||
* trivial to stub/fake-time in tests. */
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an EXPLICIT stop of a run (the user pressed Stop). This is the ONLY
|
||||
* thing that aborts a run — distinct from a browser disconnect, which leaves
|
||||
* the run going. Records `stop_requested_at` on the row (only while active) and
|
||||
* aborts the in-process controller if this replica owns the run. Returns true
|
||||
* when a stop took effect (row marked and/or controller aborted), false when
|
||||
* there was nothing active to stop.
|
||||
*/
|
||||
async requestStop(runId: string, workspaceId: string): Promise<boolean> {
|
||||
const marked = await this.runRepo.markStopRequested(runId, workspaceId);
|
||||
const entry = this.active.get(runId);
|
||||
if (entry) {
|
||||
// Abort the live turn -> streamText onAbort fires -> the partial is
|
||||
// persisted (#183) and finalizeRun settles the row as 'aborted'.
|
||||
entry.controller.abort();
|
||||
}
|
||||
return Boolean(marked) || Boolean(entry);
|
||||
}
|
||||
|
||||
/** Latest persisted run for a chat — the reconnect target (an in-flight or
|
||||
* finished run). Pure read-through to the repo. */
|
||||
getLatestForChat(
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiChatRun | undefined> {
|
||||
return this.runRepo.findLatestByChat(chatId, workspaceId);
|
||||
}
|
||||
|
||||
/** Fetch a run by id (workspace-scoped). Used to resolve + ownership-check an
|
||||
* explicit stop targeting a runId. */
|
||||
getRun(runId: string, workspaceId: string): Promise<AiChatRun | undefined> {
|
||||
return this.runRepo.findById(runId, workspaceId);
|
||||
}
|
||||
|
||||
/** The active run on a chat, if any (used to reject a concurrent start with a
|
||||
* clean 409 before committing to the stream). */
|
||||
getActiveForChat(
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiChatRun | undefined> {
|
||||
return this.runRepo.findActiveByChat(chatId, workspaceId);
|
||||
}
|
||||
|
||||
/** Test/diagnostic seam: whether this replica is holding a live controller for
|
||||
* the run. */
|
||||
isLocallyActive(runId: string): boolean {
|
||||
return this.active.has(runId);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ describe('AiChatController.boundChat', () => {
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('AiChatController.export', () => {
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never,
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #184 run-reconnect / run-stop endpoints
|
||||
* (`POST /ai-chat/run` and `POST /ai-chat/stop`). Both are OWNER-gated via
|
||||
* assertOwnedChat (the requesting user must own the chat) and NOT flag-gated.
|
||||
* Exercised with hand-rolled mocks — no Nest graph, no DB. The controller's
|
||||
* constructor order is (aiChatService, aiChatRunService, aiChatRepo,
|
||||
* aiChatMessageRepo, aiTranscription).
|
||||
*/
|
||||
describe('AiChatController run endpoints (#184)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeController(opts: {
|
||||
chat?: unknown; // what aiChatRepo.findById returns (owner-gate)
|
||||
run?: unknown; // getLatestForChat / getRun result
|
||||
activeRun?: unknown; // getActiveForChat result
|
||||
message?: unknown; // aiChatMessageRepo.findById result
|
||||
stopped?: boolean; // requestStop result
|
||||
}) {
|
||||
const aiChatRunService = {
|
||||
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
|
||||
getRun: jest.fn().mockResolvedValue(opts.run),
|
||||
getActiveForChat: jest.fn().mockResolvedValue(opts.activeRun),
|
||||
requestStop: jest.fn().mockResolvedValue(opts.stopped ?? false),
|
||||
};
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.chat),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.message),
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never, // aiChatService
|
||||
aiChatRunService as never,
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiTranscription
|
||||
);
|
||||
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
describe('POST /ai-chat/run (getRun)', () => {
|
||||
it('owner-gates: a chat the user does not own throws ForbiddenException', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
await expect(
|
||||
controller.getRun({ chatId: 'c1' }, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// It must NOT reach the run lookup once the owner-gate fails.
|
||||
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns { run: null, message: null } when the chat has never had a run', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run: undefined,
|
||||
});
|
||||
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ run: null, message: null });
|
||||
expect(aiChatRunService.getLatestForChat).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the run and its projected assistant message', async () => {
|
||||
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: 'm1' };
|
||||
const message = { id: 'm1', role: 'assistant' };
|
||||
const { controller, aiChatMessageRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run,
|
||||
message,
|
||||
});
|
||||
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ run, message });
|
||||
expect(aiChatMessageRepo.findById).toHaveBeenCalledWith('m1', 'ws1');
|
||||
});
|
||||
|
||||
it('returns message: null when the run has no linked assistant message', async () => {
|
||||
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: null };
|
||||
const { controller, aiChatMessageRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run,
|
||||
});
|
||||
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ run, message: null });
|
||||
expect(aiChatMessageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /ai-chat/stop (stopRun)', () => {
|
||||
it('throws BadRequestException when neither runId nor chatId is given', async () => {
|
||||
const { controller } = makeController({});
|
||||
await expect(
|
||||
controller.stopRun({}, user, workspace),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('stops by runId: owner-gates via the run’s chat, then requests the stop', async () => {
|
||||
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||
run: { id: 'run-1', chatId: 'c1' },
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
stopped: true,
|
||||
});
|
||||
const res = await controller.stopRun({ runId: 'run-1' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: true });
|
||||
expect(aiChatRunService.getRun).toHaveBeenCalledWith('run-1', 'ws1');
|
||||
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-1', 'ws1');
|
||||
});
|
||||
|
||||
it('stops by runId: a foreign run’s chat throws ForbiddenException (no stop)', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
run: { id: 'run-1', chatId: 'c1' },
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
await expect(
|
||||
controller.stopRun({ runId: 'run-1' }, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops by runId: an unknown run reports { stopped: false }', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
run: undefined,
|
||||
});
|
||||
const res = await controller.stopRun({ runId: 'gone' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: false });
|
||||
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops by chatId: owner-gates, resolves the active run, requests the stop', async () => {
|
||||
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
activeRun: { id: 'run-9' },
|
||||
stopped: true,
|
||||
});
|
||||
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: true });
|
||||
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
);
|
||||
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-9', 'ws1');
|
||||
});
|
||||
|
||||
it('stops by chatId: reports { stopped: false } when no run is active', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
activeRun: undefined,
|
||||
});
|
||||
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: false });
|
||||
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
@@ -21,25 +20,14 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
import {
|
||||
AiChat,
|
||||
AiChatMessage,
|
||||
AiChatRun,
|
||||
User,
|
||||
Workspace,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
import {
|
||||
AiChatRunHooks,
|
||||
AiChatService,
|
||||
AiChatStreamBody,
|
||||
} from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import {
|
||||
BoundChatDto,
|
||||
@@ -47,9 +35,7 @@ import {
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
GetChatMessagesDto,
|
||||
GetRunDto,
|
||||
RenameChatDto,
|
||||
StopRunDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
import { buildChatMarkdown } from './chat-markdown.util';
|
||||
@@ -66,7 +52,6 @@ export class AiChatController {
|
||||
|
||||
constructor(
|
||||
private readonly aiChatService: AiChatService,
|
||||
private readonly aiChatRunService: AiChatRunService,
|
||||
private readonly aiChatRepo: AiChatRepo,
|
||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||
private readonly aiTranscription: AiTranscriptionService,
|
||||
@@ -152,75 +137,6 @@ export class AiChatController {
|
||||
return { markdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest run of a chat (#184 phase 1). Returns the run's
|
||||
* persisted lifecycle state ({ status, error, stepCount, timings, ... }) plus
|
||||
* the assistant message it projects (the partial/final output) — the DB is the
|
||||
* source of truth, so this works for an in-flight run (the browser dropped, the
|
||||
* run kept going) and a finished one alike. Owner-gated via assertOwnedChat.
|
||||
* `{ run: null }` when the chat has never had a run.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('run')
|
||||
async getRun(
|
||||
@Body() dto: GetRunDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ run: AiChatRun | null; message: AiChatMessage | null }> {
|
||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const run = await this.aiChatRunService.getLatestForChat(
|
||||
dto.chatId,
|
||||
workspace.id,
|
||||
);
|
||||
if (!run) return { run: null, message: null };
|
||||
const message = run.assistantMessageId
|
||||
? await this.aiChatMessageRepo.findById(
|
||||
run.assistantMessageId,
|
||||
workspace.id,
|
||||
)
|
||||
: undefined;
|
||||
return { run, message: message ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP an agent run (#184 phase 1) — the user pressed Stop. This is
|
||||
* the ONLY thing that ends a detached run; a browser disconnect deliberately
|
||||
* does not. Target by `runId` (from the streamed start metadata) or by `chatId`
|
||||
* (stop whatever run is active on it). Owner-gated. Returns
|
||||
* `{ stopped }` — false when there was nothing active to stop.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('stop')
|
||||
async stopRun(
|
||||
@Body() dto: StopRunDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ stopped: boolean }> {
|
||||
let runId = dto.runId;
|
||||
if (!runId && !dto.chatId) {
|
||||
throw new BadRequestException('runId or chatId is required');
|
||||
}
|
||||
if (runId) {
|
||||
// Resolve the run to its chat and owner-gate via that chat.
|
||||
const run = await this.aiChatRunService.getRun(runId, workspace.id);
|
||||
if (!run) return { stopped: false };
|
||||
await this.assertOwnedChat(run.chatId, user, workspace);
|
||||
} else {
|
||||
await this.assertOwnedChat(dto.chatId!, user, workspace);
|
||||
const active = await this.aiChatRunService.getActiveForChat(
|
||||
dto.chatId!,
|
||||
workspace.id,
|
||||
);
|
||||
if (!active) return { stopped: false };
|
||||
runId = active.id;
|
||||
}
|
||||
const stopped = await this.aiChatRunService.requestStop(
|
||||
runId,
|
||||
workspace.id,
|
||||
);
|
||||
return { stopped };
|
||||
}
|
||||
|
||||
/** Rename a chat. */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('rename')
|
||||
@@ -272,20 +188,11 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
// A7 gate: the workspace must have AI chat explicitly enabled.
|
||||
const settings = (workspace.settings ?? {}) as {
|
||||
ai?: { chat?: boolean; autonomousRuns?: boolean };
|
||||
};
|
||||
const settings = (workspace.settings ?? {}) as { ai?: { chat?: boolean } };
|
||||
if (settings.ai?.chat !== true) {
|
||||
throw new ForbiddenException('AI chat is disabled');
|
||||
}
|
||||
|
||||
// #184 phase 1 flag: when ON, the turn becomes a detached, durable RUN — its
|
||||
// lifecycle is tracked in ai_chat_runs, a browser disconnect no longer aborts
|
||||
// it, and only an explicit /ai-chat/stop ends it. When OFF (the default) the
|
||||
// turn is socket-bound exactly as before, so existing deployments are
|
||||
// unaffected.
|
||||
const autonomousRuns = settings.ai?.autonomousRuns === true;
|
||||
|
||||
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
||||
if (!sessionId) {
|
||||
// The chat requires an interactive session to mint loopback tokens
|
||||
@@ -309,58 +216,6 @@ export class AiChatController {
|
||||
// HttpException) instead of breaking mid-stream.
|
||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
||||
|
||||
// #184: one active run per chat. For an EXISTING chat reject a concurrent
|
||||
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
|
||||
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
|
||||
// (no chatId) cannot have a prior run, and the DB partial unique index is the
|
||||
// backstop against any race that slips past this check.
|
||||
if (autonomousRuns && body.chatId) {
|
||||
const active = await this.aiChatRunService.getActiveForChat(
|
||||
body.chatId,
|
||||
workspace.id,
|
||||
);
|
||||
if (active) {
|
||||
throw new ConflictException({
|
||||
message: 'An agent run is already in progress for this chat',
|
||||
code: 'A_RUN_ALREADY_ACTIVE',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
|
||||
// a durable run whose abort is governed by the run (explicit stop), persist
|
||||
// its progress, and settle its terminal status — see AiChatRunService.
|
||||
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
||||
? {
|
||||
begin: (chatId) =>
|
||||
this.aiChatRunService.beginRun({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
trigger: 'user',
|
||||
}),
|
||||
onAssistantSeeded: (runId, messageId) =>
|
||||
this.aiChatRunService.linkAssistantMessage(
|
||||
runId,
|
||||
workspace.id,
|
||||
messageId,
|
||||
),
|
||||
onStep: (runId, stepCount) =>
|
||||
void this.aiChatRunService.recordStep(
|
||||
runId,
|
||||
workspace.id,
|
||||
stepCount,
|
||||
),
|
||||
onSettled: (runId, status, error) =>
|
||||
this.aiChatRunService.finalizeRun(
|
||||
runId,
|
||||
workspace.id,
|
||||
status,
|
||||
error,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||
// normal completion, so only abort when the response has not finished
|
||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
||||
@@ -375,44 +230,18 @@ export class AiChatController {
|
||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||
// so log it here.
|
||||
// so log it here before aborting the agent loop.
|
||||
if (!res.raw.writableEnded) {
|
||||
if (autonomousRuns) {
|
||||
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
||||
// the run keeps executing and persisting server-side; the client
|
||||
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||
this.logger.log(
|
||||
`AI chat stream: client disconnected; run continues server-side ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
this.logger.warn(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
req.raw.once('close', onClose);
|
||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||
|
||||
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
|
||||
// pipe keeps writing to a socket the client may have dropped — for the rest of
|
||||
// the (continuing) run. A write to the dead socket can emit an 'error' on the
|
||||
// raw response; without a listener that surfaces as an unhandled error event.
|
||||
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
|
||||
// disconnect, so it does not need this and keeps its exact prior behavior.
|
||||
if (autonomousRuns) {
|
||||
res.raw.on('error', (err) => {
|
||||
this.logger.debug(
|
||||
`AI chat detached stream: post-disconnect socket error swallowed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Commit to streaming: hijack so Fastify stops managing the response and
|
||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||
res.hijack();
|
||||
@@ -427,32 +256,15 @@ export class AiChatController {
|
||||
signal: controller.signal,
|
||||
model,
|
||||
role,
|
||||
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||
runHooks,
|
||||
});
|
||||
} catch (err) {
|
||||
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||
// filter, so emit the error on the raw socket if nothing has been written
|
||||
// yet. The lost-the-race 409 (RunAlreadyActiveError -> ConflictException)
|
||||
// is raised by stream() BEFORE it writes a byte, so headers are still
|
||||
// unsent here: honor the HttpException's real status + body (a clean 409),
|
||||
// not a blanket 500. Everything else stays a 500.
|
||||
const isHttp = err instanceof HttpException;
|
||||
if (!isHttp) {
|
||||
this.logger.error('AI chat stream failed', err as Error);
|
||||
}
|
||||
// Any failure AFTER hijack can no longer send a clean JSON error, so emit
|
||||
// a minimal error on the raw socket if nothing has been written yet.
|
||||
this.logger.error('AI chat stream failed', err as Error);
|
||||
if (!res.raw.headersSent) {
|
||||
const status = isHttp ? err.getStatus() : 500;
|
||||
const payload = isHttp
|
||||
? err.getResponse()
|
||||
: { error: 'Internal server error' };
|
||||
res.raw.statusCode = status;
|
||||
res.raw.statusCode = 500;
|
||||
res.raw.setHeader('Content-Type', 'application/json');
|
||||
res.raw.end(
|
||||
JSON.stringify(
|
||||
typeof payload === 'string' ? { message: payload } : payload,
|
||||
),
|
||||
);
|
||||
res.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
||||
} else if (!res.raw.writableEnded) {
|
||||
res.raw.end();
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ describe('AiChatController.generatePageTitle', () => {
|
||||
const aiChatService = { generatePageTitle: generate };
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
{} as never, // aiChatRunService
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AiModule } from '../../integrations/ai/ai.module';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { EmbeddingModule } from './embedding/embedding.module';
|
||||
@@ -43,7 +42,6 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
||||
controllers: [AiChatController, PublicShareChatController],
|
||||
providers: [
|
||||
AiChatService,
|
||||
AiChatRunService,
|
||||
AiTranscriptionService,
|
||||
AiChatToolsService,
|
||||
PublicShareChatService,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AiChatService, AiChatRunHooks } from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
|
||||
/**
|
||||
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
||||
@@ -61,97 +59,3 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #184 CRITICAL run-lifecycle safety net (review fix). A transient failure
|
||||
* AFTER a successful beginRun but BEFORE streamText's terminal callbacks own the
|
||||
* lifecycle must STILL settle the run — otherwise the run row is stuck 'running'
|
||||
* forever (sweepRunning only runs at startup) and the partial unique index + the
|
||||
* controller pre-check 409 every future turn in that chat until a restart. Here
|
||||
* we model the very first bare await after beginRun (the user-message insert)
|
||||
* throwing, wiring the run hooks to a REAL AiChatRunService (mock repo) exactly
|
||||
* as the controller does, and assert the run is settled to 'error' and its
|
||||
* in-memory entry dropped (so a follow-up turn would NOT be 409'd).
|
||||
*/
|
||||
describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('an exception after beginRun settles the run to error and drops the in-memory entry', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
// Real run service over a mock repo, so finalizeRun's in-memory bookkeeping
|
||||
// (active.delete) is exercised for real.
|
||||
const runRepo = {
|
||||
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||
};
|
||||
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
||||
|
||||
// The user-message insert (the first bare await after beginRun) throws.
|
||||
const aiChatMessageRepo = {
|
||||
insert: jest.fn().mockRejectedValue(new Error('insert boom')),
|
||||
};
|
||||
const aiChatRepo = {
|
||||
// Existing chat -> chatId stays, no new-chat insert path.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'chat-1', creatorId: 'u1' }),
|
||||
};
|
||||
|
||||
const service = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiSettings
|
||||
{} as never, // tools
|
||||
{} as never, // mcpClients
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
);
|
||||
|
||||
const runHooks: AiChatRunHooks = {
|
||||
begin: (chatId) =>
|
||||
runService.beginRun({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
trigger: 'user',
|
||||
}),
|
||||
onSettled: (runId, status, error) =>
|
||||
runService.finalizeRun(runId, workspace.id, status, error),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.stream({
|
||||
user,
|
||||
workspace,
|
||||
sessionId: 'sess',
|
||||
body: {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
res: {} as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks,
|
||||
}),
|
||||
).rejects.toThrow('insert boom');
|
||||
|
||||
// The run was begun...
|
||||
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
||||
// ...then settled to a terminal FAILED status by the safety net...
|
||||
expect(runRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(runRepo.update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws1',
|
||||
expect.objectContaining({ status: 'failed' }),
|
||||
);
|
||||
// ...and the in-memory entry is gone, so a follow-up turn is NOT 409'd.
|
||||
expect(runService.isLocallyActive('run-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
|
||||
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
||||
// about to reject. The race rejection happens at runHooks.begin(), long before
|
||||
// any streamText/generateText, so these never resolve a real model.
|
||||
jest.mock('ai', () => ({
|
||||
streamText: jest.fn(),
|
||||
generateText: jest.fn(),
|
||||
convertToModelMessages: jest.fn(() => []),
|
||||
stepCountIs: jest.fn(() => () => false),
|
||||
}));
|
||||
|
||||
import { streamText, generateText } from 'ai';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||
|
||||
/**
|
||||
* Race-closure coverage for the "one active run per chat" guard (#184).
|
||||
*
|
||||
* THE BUG: two simultaneous POST /ai-chat/stream on the same chat both pass the
|
||||
* controller's cheap pre-check (TOCTOU), so the loser's run-row INSERT hits the
|
||||
* partial unique index. Previously that 23505 was SWALLOWED and the second turn
|
||||
* streamed UNTRACKED (no runId, not stoppable). THE FIX: beginRun surfaces a
|
||||
* RunAlreadyActiveError and stream() turns it into a 409 BEFORE any AI call —
|
||||
* the second turn never runs.
|
||||
*/
|
||||
describe('AiChatService.stream — concurrent-run race rejection (#184)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
const generateTextMock = generateText as unknown as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
generateTextMock.mockReset();
|
||||
});
|
||||
|
||||
// Minimal service whose only reachable deps before begin() are aiChatRepo
|
||||
// (resolve the existing chat) — everything past begin must remain untouched.
|
||||
function makeService(beginImpl: () => Promise<unknown>) {
|
||||
const aiChatMessageRepo = { insert: jest.fn() };
|
||||
const aiChatRepo = {
|
||||
// An existing chat: stream keeps the supplied chatId and skips creation.
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiSettings
|
||||
{} as never, // tools
|
||||
{} as never, // mcpClients
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
);
|
||||
const begin = jest.fn(beginImpl);
|
||||
return { svc, begin, aiChatRepo, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
const baseArgs = (begin: jest.Mock) => ({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: { chatId: 'chat-1', messages: [] } as never,
|
||||
res: { raw: {} } as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: {
|
||||
begin,
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
it('rejects the racer with a 409 ConflictException BEFORE any AI call, and never persists an untracked turn', async () => {
|
||||
// begin loses the unique-index race -> RunAlreadyActiveError.
|
||||
const { svc, begin, aiChatMessageRepo } = makeService(() => {
|
||||
throw new RunAlreadyActiveError('chat-1');
|
||||
});
|
||||
|
||||
const promise = svc.stream(baseArgs(begin));
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(ConflictException);
|
||||
await promise.catch((err: ConflictException) => {
|
||||
expect(err.getStatus()).toBe(409);
|
||||
expect((err.getResponse() as { code?: string }).code).toBe(
|
||||
'A_RUN_ALREADY_ACTIVE',
|
||||
);
|
||||
});
|
||||
|
||||
// The decisive assertions: the rejected racer spent NO tokens and left NO
|
||||
// untracked turn behind.
|
||||
expect(begin).toHaveBeenCalledTimes(1);
|
||||
expect(streamTextMock).not.toHaveBeenCalled();
|
||||
expect(generateTextMock).not.toHaveBeenCalled();
|
||||
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* F3 — the LOAD-BEARING run-detach wiring: `effectiveSignal = handle.signal`
|
||||
* after runHooks.begin, then `abortSignal: effectiveSignal` passed to streamText.
|
||||
* That single line is what makes a run survive a browser disconnect (the agent
|
||||
* loop's abort is governed by the RUN's signal, not the socket): a regression to
|
||||
* the socket-bound signal would still pass every other test green while silently
|
||||
* breaking Stop + durability. These two tests pin the exact signal streamText
|
||||
* consumes on both paths.
|
||||
*/
|
||||
describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
|
||||
// A streamText result stub: the post-call drain + pipe are no-ops here; we only
|
||||
// care WHICH abortSignal streamText was handed.
|
||||
function makeStreamResult() {
|
||||
return {
|
||||
consumeStream: jest.fn(),
|
||||
pipeUIMessageStreamToResponse: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// A raw-response stub sufficient for the post-streamText wiring
|
||||
// (stripStreamingHopByHopHeaders binds writeHead; startSseHeartbeat registers
|
||||
// close/finish listeners; flushHeaders is belt-and-braces).
|
||||
function makeRes() {
|
||||
return {
|
||||
raw: {
|
||||
writeHead: jest.fn(),
|
||||
write: jest.fn(),
|
||||
once: jest.fn(),
|
||||
on: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Wire only the deps reached on the way to streamText: resolve the existing
|
||||
// chat, persist the user + seed the assistant row, load (empty) history, the
|
||||
// admin settings, an empty external toolset + Docmost toolset.
|
||||
function makeService() {
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
const mcpClients = {
|
||||
toolsFor: jest.fn(async () => ({
|
||||
tools: {},
|
||||
clients: [],
|
||||
outcomes: [],
|
||||
instructions: [],
|
||||
})),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
aiSettings as never,
|
||||
tools as never,
|
||||
mcpClients as never,
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo (openPage undefined -> never touched)
|
||||
{} as never, // pageAccess
|
||||
);
|
||||
return { svc };
|
||||
}
|
||||
|
||||
const body = {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
streamTextMock.mockImplementation(() => makeStreamResult());
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('happy path (run-wrapped): streamText is driven with abortSignal === handle.signal (the RUN signal, NOT the socket)', async () => {
|
||||
const { svc } = makeService();
|
||||
const runController = new AbortController();
|
||||
const runSignal = runController.signal;
|
||||
const socketSignal = new AbortController().signal;
|
||||
|
||||
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: socketSignal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: {
|
||||
begin,
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(begin).toHaveBeenCalledTimes(1);
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
// THE assertion: the agent loop's abort is wired to the RUN, so a browser
|
||||
// disconnect (which aborts only `socketSignal`) cannot end the turn.
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(runSignal);
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).not.toBe(socketSignal);
|
||||
});
|
||||
|
||||
it('legacy path (no runHooks): streamText is driven with the SOCKET signal', async () => {
|
||||
const { svc } = makeService();
|
||||
const socketSignal = new AbortController().signal;
|
||||
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: socketSignal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
// No runHooks -> the turn stays socket-bound (flag off / default).
|
||||
});
|
||||
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
|
||||
});
|
||||
|
||||
/**
|
||||
* F9 — streamText's TERMINAL callbacks carry the #184 run lifecycle:
|
||||
* onStepFinish -> runHooks.onStep(runId, stepCount)
|
||||
* onFinish -> runHooks.onSettled(runId, 'completed') (dominant path)
|
||||
* onAbort -> runHooks.onSettled(runId, 'aborted')
|
||||
* onError -> runHooks.onSettled(runId, 'error', cause)
|
||||
* makeStreamResult() ignores the streamText options, so these callbacks never
|
||||
* fire on their own — a regression in this wiring (esp. the success path) would
|
||||
* strand the run with NO test catching it. Here we CAPTURE the options streamText
|
||||
* was handed and invoke each callback with the real wiring, asserting the run
|
||||
* hooks fire with the right args.
|
||||
*/
|
||||
// Drive stream() to the point streamText is called, capturing the options object
|
||||
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
||||
async function captureStreamCallbacks() {
|
||||
const { svc } = makeService();
|
||||
let capturedOpts: any;
|
||||
streamTextMock.mockImplementation((opts: any) => {
|
||||
capturedOpts = opts;
|
||||
return makeStreamResult();
|
||||
});
|
||||
const runHooks = {
|
||||
begin: jest.fn(async () => ({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
})),
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
};
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: runHooks as never,
|
||||
});
|
||||
expect(capturedOpts).toBeDefined();
|
||||
return { capturedOpts, runHooks };
|
||||
}
|
||||
|
||||
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
||||
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||
|
||||
// A finished step -> onStep(runId, finishedStepCount).
|
||||
capturedOpts.onStepFinish({ text: 'step one', toolCalls: [], content: [] });
|
||||
expect(runHooks.onStep).toHaveBeenCalledWith('run-1', 1);
|
||||
capturedOpts.onStepFinish({ text: 'step two', toolCalls: [], content: [] });
|
||||
expect(runHooks.onStep).toHaveBeenLastCalledWith('run-1', 2);
|
||||
|
||||
// The success terminal callback settles the run.
|
||||
await capturedOpts.onFinish({
|
||||
text: 'done',
|
||||
finishReason: 'stop',
|
||||
totalUsage: {},
|
||||
usage: {},
|
||||
steps: [],
|
||||
});
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
|
||||
});
|
||||
|
||||
it('F9: onAbort settles the run "aborted"', async () => {
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||
|
||||
await capturedOpts.onAbort({ steps: [] });
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'aborted');
|
||||
});
|
||||
|
||||
it('F9: onError settles the run "error" carrying the provider cause', async () => {
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||
|
||||
await capturedOpts.onError({ error: new Error('provider exploded') });
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'error',
|
||||
expect.stringContaining('provider exploded'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -371,12 +371,6 @@ describe('chatStreamMetadata', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the runId on the start part when a run wraps the turn (#184)', () => {
|
||||
expect(
|
||||
chatStreamMetadata({ type: 'start' }, 'chat-1', undefined, 'run-1'),
|
||||
).toEqual({ chatId: 'chat-1', runId: 'run-1' });
|
||||
});
|
||||
|
||||
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
||||
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||
// running sum, which this just wraps.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,30 +43,6 @@ export class BoundChatDto {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest run of a chat (#184): fetch its persisted lifecycle
|
||||
* state (and the assistant message it projects) for an in-flight or finished run.
|
||||
*/
|
||||
export class GetRunDto {
|
||||
@IsString()
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP an agent run (#184): the user pressed Stop — distinct from a
|
||||
* browser disconnect, which never stops a run. Either the run id (preferred, from
|
||||
* the streamed start metadata) or the chat id (stop whatever run is active on it).
|
||||
*/
|
||||
export class StopRunDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
runId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
chatId?: string;
|
||||
}
|
||||
|
||||
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||
* role/tool-action labels; defaults to English server-side. */
|
||||
export class ExportChatDto {
|
||||
|
||||
@@ -19,4 +19,87 @@ describe('AuthController', () => {
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
// The EE MFA module is absent in this repo, so require() throws and is caught;
|
||||
// login falls through to authService.login -> setAuthCookie -> returnToken.
|
||||
describe('login returnToken branch', () => {
|
||||
const workspace = { id: 'ws1', enforceSso: false };
|
||||
|
||||
const makeController = () => {
|
||||
const authService = {
|
||||
login: jest.fn().mockResolvedValue('jwt-token-123'),
|
||||
};
|
||||
const environmentService = {
|
||||
getCookieExpiresIn: jest.fn().mockReturnValue(new Date()),
|
||||
isHttps: jest.fn().mockReturnValue(false),
|
||||
};
|
||||
const ctrl = new AuthController(
|
||||
authService as any,
|
||||
{} as any,
|
||||
environmentService as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
const res = { setCookie: jest.fn() };
|
||||
return { ctrl, authService, res };
|
||||
};
|
||||
|
||||
it('returns the body token and sets the cookie when returnToken is true', async () => {
|
||||
const { ctrl, authService, res } = makeController();
|
||||
const loginInput = {
|
||||
email: 'a@b.com',
|
||||
password: 'pw',
|
||||
returnToken: true,
|
||||
};
|
||||
|
||||
const result = await ctrl.login(
|
||||
workspace as any,
|
||||
res as any,
|
||||
loginInput as any,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ authToken: 'jwt-token-123' });
|
||||
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||
expect(res.setCookie).toHaveBeenCalledWith(
|
||||
'authToken',
|
||||
'jwt-token-123',
|
||||
expect.objectContaining({ httpOnly: true }),
|
||||
);
|
||||
expect(authService.login).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns no body token but still sets the cookie when returnToken is omitted', async () => {
|
||||
const { ctrl, res } = makeController();
|
||||
const loginInput = { email: 'a@b.com', password: 'pw' };
|
||||
|
||||
const result = await ctrl.login(
|
||||
workspace as any,
|
||||
res as any,
|
||||
loginInput as any,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Guards against an `!== undefined`-style bug: an explicit `false` must
|
||||
// behave exactly like the omitted case (cookie set, no token in the body).
|
||||
it('returns no body token but still sets the cookie when returnToken is false', async () => {
|
||||
const { ctrl, res } = makeController();
|
||||
const loginInput = {
|
||||
email: 'a@b.com',
|
||||
password: 'pw',
|
||||
returnToken: false,
|
||||
};
|
||||
|
||||
const result = await ctrl.login(
|
||||
workspace as any,
|
||||
res as any,
|
||||
loginInput as any,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(res.setCookie).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,12 @@ export class AuthController {
|
||||
} else if (mfaResult.authToken) {
|
||||
// User doesn't have MFA and workspace doesn't require it
|
||||
this.setAuthCookie(res, mfaResult.authToken);
|
||||
// Opt-in body token for native clients (Bearer auth). The response is
|
||||
// wrapped by TransformHttpResponseInterceptor, so clients read it at
|
||||
// `data.authToken`. Web clients omit returnToken and keep the cookie.
|
||||
if (loginInput.returnToken) {
|
||||
return { authToken: mfaResult.authToken };
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -104,6 +110,12 @@ export class AuthController {
|
||||
|
||||
const authToken = await this.authService.login(loginInput, workspace.id);
|
||||
this.setAuthCookie(res, authToken);
|
||||
// Opt-in body token for native clients (Bearer auth). The response is wrapped
|
||||
// by TransformHttpResponseInterceptor, so clients read it at `data.authToken`.
|
||||
// Web clients omit returnToken and keep using the httpOnly cookie only.
|
||||
if (loginInput.returnToken) {
|
||||
return { authToken };
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(SetupGuard)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsNotEmpty()
|
||||
@@ -8,4 +14,13 @@ export class LoginDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
// When true, the access token is returned in the response body (in addition
|
||||
// to the httpOnly cookie) so native/mobile clients can store it in
|
||||
// Keychain/Keystore and send it as 'Authorization: Bearer'. Web clients omit
|
||||
// this flag and keep using the cookie. Opt-in only: the token is never put in
|
||||
// the body otherwise.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
returnToken?: boolean;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,102 @@ describe('PageService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('update — title sync into collab doc (Bug 1)', () => {
|
||||
const makeUpdateService = () => {
|
||||
const pageRepo = {
|
||||
updatePage: jest.fn().mockResolvedValue(undefined),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'page-1' }),
|
||||
};
|
||||
const generalQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||
const collaborationGateway = {
|
||||
writePageTitle: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const svc = new PageService(
|
||||
pageRepo as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // db
|
||||
{} as any, // storageService
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // aiQueue
|
||||
generalQueue as any, // generalQueue
|
||||
{} as any, // eventEmitter
|
||||
collaborationGateway as any, // collaborationGateway
|
||||
{} as any, // watcherService
|
||||
{} as any, // transclusionService
|
||||
);
|
||||
|
||||
return { svc, pageRepo, collaborationGateway };
|
||||
};
|
||||
|
||||
const basePage = (): Page =>
|
||||
({
|
||||
id: 'page-1',
|
||||
slugId: 'slug-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
parentPageId: null,
|
||||
title: 'Old Title',
|
||||
icon: null,
|
||||
contributorIds: [],
|
||||
}) as any;
|
||||
|
||||
const user = { id: 'u1' } as any;
|
||||
|
||||
it('writes the new title into the collab doc when the title actually changed', async () => {
|
||||
const { svc, collaborationGateway } = makeUpdateService();
|
||||
|
||||
await svc.update(basePage(), { title: 'New Title' } as any, user);
|
||||
|
||||
// Must use the Redis-independent writePageTitle (direct
|
||||
// openDirectConnection), NOT handleYjsEvent which no-ops without Redis.
|
||||
expect(collaborationGateway.writePageTitle).toHaveBeenCalledTimes(1);
|
||||
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
'New Title',
|
||||
expect.objectContaining({ user }),
|
||||
);
|
||||
});
|
||||
|
||||
it('threads agent provenance into the collab title write', async () => {
|
||||
const { svc, collaborationGateway } = makeUpdateService();
|
||||
|
||||
await svc.update(basePage(), { title: 'New Title' } as any, user, {
|
||||
actor: 'agent',
|
||||
aiChatId: 'chat-1',
|
||||
} as any);
|
||||
|
||||
expect(collaborationGateway.writePageTitle).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
'New Title',
|
||||
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT write into the collab doc when the title is unchanged', async () => {
|
||||
const { svc, collaborationGateway } = makeUpdateService();
|
||||
|
||||
// Same title -> titleChanged is false; an icon-only change must not fire
|
||||
// the title sync.
|
||||
await svc.update(
|
||||
basePage(),
|
||||
{ title: 'Old Title', icon: '📄' } as any,
|
||||
user,
|
||||
);
|
||||
|
||||
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT write into the collab doc when the DTO omits the title', async () => {
|
||||
const { svc, collaborationGateway } = makeUpdateService();
|
||||
|
||||
await svc.update(basePage(), { icon: '📄' } as any, user);
|
||||
|
||||
expect(collaborationGateway.writePageTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePage cycle guard (#67)', () => {
|
||||
// A valid fractional-indexing key — movePage validates `position` by feeding
|
||||
// it to generateJitteredKeyBetween(position, null) before anything else.
|
||||
|
||||
@@ -265,6 +265,8 @@ export class PageService {
|
||||
contributors.add(user.id);
|
||||
const contributorIds = Array.from(contributors);
|
||||
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
|
||||
// Detect a real title/icon change so the WS tree listener can broadcast an
|
||||
// `updateOne` to the space (rename / icon swap) WITHOUT re-broadcasting on a
|
||||
// content-only save. Only treat a field as changed when the DTO actually
|
||||
@@ -307,6 +309,43 @@ export class PageService {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Bug 1: a REST/MCP rename wrote the new title ONLY to the page.title DB
|
||||
// column above. The title's source of truth is the Yjs 'title' fragment in
|
||||
// the page's collab doc, which onStoreDocument re-extracts on every save —
|
||||
// so leaving the fragment stale would REVERT this rename on the page's next
|
||||
// collaborative save (and re-broadcast the old title). Push the new title
|
||||
// into the Yjs 'title' fragment so Yjs stays in sync and never reverts.
|
||||
//
|
||||
// Use the gateway's writePageTitle (direct openDirectConnection) rather than
|
||||
// a Redis-routed handleYjsEvent path: handleYjsEvent routes through
|
||||
// redisSync and SILENTLY no-ops when Redis is disabled
|
||||
// (COLLAB_DISABLE_REDIS=true), which would let the rename revert in a
|
||||
// single-process deployment. writePageTitle is Redis-independent and
|
||||
// openDirectConnection loads the doc from persistence when no editor is
|
||||
// connected, so this also works for an offline page. Thread agent provenance
|
||||
// into the context so onStoreDocument tags the title store 'agent' too.
|
||||
if (titleChanged) {
|
||||
try {
|
||||
await this.collaborationGateway.writePageTitle(
|
||||
page.id,
|
||||
updatePageDto.title,
|
||||
{
|
||||
user,
|
||||
...(isAgent
|
||||
? { actor: 'agent', aiChatId: provenance.aiChatId }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// The DB column write already succeeded (fast-read source stays
|
||||
// correct); a failure to sync Yjs here must not fail the rename. Log so
|
||||
// a persistent desync is visible.
|
||||
this.logger.warn(
|
||||
`Failed to sync renamed title into collab doc for page ${page.id}: ${err?.['message']}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [user.id],
|
||||
|
||||
@@ -31,7 +31,6 @@ import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo';
|
||||
import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
@@ -105,7 +104,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
TemplateRepo,
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
@@ -139,7 +137,6 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
TemplateRepo,
|
||||
AiChatRepo,
|
||||
AiChatMessageRepo,
|
||||
AiChatRunRepo,
|
||||
AiProviderCredentialsRepo,
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* `ai_chat_runs` — the agent RUN as a first-class, server-side lifecycle object
|
||||
* (#184 phase 1: autonomous agent runs detached from the browser window).
|
||||
*
|
||||
* Until now an agent turn lived ONLY as long as the HTTP request was open
|
||||
* (`res.hijack()` in ai-chat.controller.ts); a browser disconnect aborted it.
|
||||
* This table makes a turn a persistent object the server owns: it is created
|
||||
* when a run starts, transitions pending -> running -> succeeded|failed|aborted,
|
||||
* and survives the subscriber (browser) going away. The DB is the source of
|
||||
* truth — a later client reconnects/sees the result by reading this row plus the
|
||||
* assistant message it projects (`assistant_message_id`).
|
||||
*
|
||||
* The assistant message row (#183 step-granular durability) is the PROJECTION of
|
||||
* a run's output; this row is the run's LIFECYCLE. They are linked by
|
||||
* `assistant_message_id` (SET NULL if the message is later pruned).
|
||||
*
|
||||
* `status` : 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'.
|
||||
* `trigger` : 'user' | 'autostart' | 'schedule' | 'api' | 'continue' — only
|
||||
* 'user' is produced in phase 1; the others are reserved for the
|
||||
* autonomy triggers deferred to phase 2 so they need no later
|
||||
* migration.
|
||||
*
|
||||
* ONE ACTIVE RUN PER CHAT is enforced by a partial unique index on `chat_id`
|
||||
* WHERE status IN ('pending','running'): an autonomous run and a user run can
|
||||
* never trample each other on the same chat. Settled runs (succeeded/failed/
|
||||
* aborted) are excluded from the index so a chat can accumulate any number of
|
||||
* historical runs.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('ai_chat_runs')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('chat_id', 'uuid', (col) =>
|
||||
col.references('ai_chats.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
// The human who triggered the run (audit). SET NULL on user deletion so the
|
||||
// run history outlives its author; NULL is also the natural value for a
|
||||
// future system/cron/api trigger with no human actor.
|
||||
.addColumn('created_by', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
// The assistant message this run materializes (the #183 projection). SET NULL
|
||||
// if that message row is later deleted; nullable because the run row is
|
||||
// created a moment BEFORE the assistant row is seeded.
|
||||
.addColumn('assistant_message_id', 'uuid', (col) =>
|
||||
col.references('ai_chat_messages.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('trigger', 'varchar(20)', (col) =>
|
||||
col.notNull().defaultTo('user'),
|
||||
)
|
||||
.addColumn('status', 'varchar(20)', (col) =>
|
||||
col.notNull().defaultTo('pending'),
|
||||
)
|
||||
// Terminal error message for a failed run (provider/transport cause),
|
||||
// mirroring the assistant message's metadata.error.
|
||||
.addColumn('error', 'text', (col) => col)
|
||||
// Number of agent steps finished so far (kept monotonic with the projection).
|
||||
.addColumn('step_count', 'integer', (col) => col.notNull().defaultTo(0))
|
||||
// Set when an EXPLICIT user stop is requested (distinct from a mere browser
|
||||
// disconnect, which never stops a run). The runner aborts the turn and the
|
||||
// run settles as 'aborted'.
|
||||
.addColumn('stop_requested_at', 'timestamptz', (col) => col)
|
||||
.addColumn('started_at', 'timestamptz', (col) => col)
|
||||
.addColumn('finished_at', 'timestamptz', (col) => col)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Reconnect / "latest run for this chat" reads hit chat_id first.
|
||||
await db.schema
|
||||
.createIndex('ai_chat_runs_chat_id_idx')
|
||||
.ifNotExists()
|
||||
.on('ai_chat_runs')
|
||||
.column('chat_id')
|
||||
.execute();
|
||||
|
||||
// One ACTIVE run per chat (advisory at the DB level): a second pending/running
|
||||
// run on the same chat is rejected, so a user turn and an autonomous turn can
|
||||
// never race on the same chat. Partial so settled runs do not collide.
|
||||
await db.schema
|
||||
.createIndex('ai_chat_runs_one_active_per_chat')
|
||||
.ifNotExists()
|
||||
.on('ai_chat_runs')
|
||||
.column('chat_id')
|
||||
.unique()
|
||||
.where(sql.ref('status'), 'in', sql`('pending','running')`)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('ai_chat_runs').execute();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user