4-10 树域 Rust 家族化

This commit is contained in:
lix-2026
2026-04-24 06:10:18 +08:00
parent 41e958769e
commit 94631f3636
49 changed files with 5751 additions and 557 deletions
@@ -1,119 +1,83 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCreatePayload,
} from "@/lib/documents/page-command-adapter";
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
type TreeCreateResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
execution?: {
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} | null;
} | null;
};
export async function POST(request: Request) {
try {
if (isConvexEnabled()) {
return await handleCreateRequestConvex(request);
}
return await handleCreateRequest();
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequestConvex(request: Request) {
const { auth, client } = await getAuthedConvexClient();
const { parentId }: { parentId?: string | null } = await request.json();
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(),
});
let workspaceId: string | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, {
id: parentId,
});
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const id = randomUUID();
const normalizedWorkspaceId = workspaceId.trim();
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCreatePayload,
{
id: string;
title?: string | null;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string;
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.create",
payload: {
documentId: id,
workspaceId: normalizedWorkspaceId,
parentId: parentId?.trim() || null,
title: "无标题",
accessScope,
content: [],
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: id,
},
const { parentId }: { parentId?: string | null } = await request.json();
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "create",
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
}),
});
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
const payload = (await response.json().catch(() => null)) as TreeCreateResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "创建页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
...result.result,
id: payload?.result?.documentId ?? "",
title: payload?.result?.title ?? "无标题",
parent_id: payload?.result?.parentId ?? null,
sort_order: payload?.result?.sortOrder ?? null,
workspace_id: payload?.result?.workspaceId ?? undefined,
access_scope: payload?.result?.execution?.access_scope ?? "private",
is_template: payload?.result?.execution?.is_template ?? false,
created_at: payload?.result?.execution?.created_at ?? null,
updated_at:
payload?.result?.execution?.updated_at ??
payload?.result?.execution?.created_at ??
null,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.create",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "创建页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
async function handleCreateRequest() {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
export const runtime = "nodejs";
@@ -1,67 +1,79 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentMovePayload,
} from "@/lib/documents/page-command-adapter";
interface MovePayload {
documentId: string;
type TreeMoveResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
sortOrder?: number | null;
} | null;
};
type MovePayload = {
documentId?: string | null;
parentId?: string | null;
position: number;
position?: number | null;
workspaceId?: string | null;
}
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedWorkspaceId = workspaceId?.trim() || null;
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
parentId: parentId?.trim() || null,
sortOrder,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
ok: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "move",
documentId: normalizedDocumentId,
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
sortOrder: typeof position === "number" && Number.isFinite(position) ? Math.floor(position) : 0,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as TreeMoveResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "移动失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.subtree.move",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "移动失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -79,6 +79,8 @@ import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { POST as postCreate } from "@/app/api/documents/create/route";
import { POST as postMove } from "@/app/api/documents/move/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
@@ -91,6 +93,151 @@ import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-comman
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
describe("documents route adapters", () => {
it("create route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_create_1",
traceId: "trace_tree_create_1",
result: {
action: "create",
workspaceId: "ws_1",
documentId: "doc_new",
parentId: null,
title: "无标题",
sortOrder: 0,
updatedAt: "2026-04-23T00:00:00Z",
execution: {
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCreate(new Request("http://localhost/api/documents/create", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parentId: null }),
}));
const payload = await response.json() as {
id: string;
workspace_id: string;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "create",
parentId: null,
}),
}),
);
expect(payload.id).toBe("doc_new");
expect(payload.workspace_id).toBe("ws_1");
expect(payload.meta.commandName).toBe("tree.node.create");
});
it("move route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_move_1",
traceId: "trace_tree_move_1",
result: {
action: "move",
workspaceId: "ws_1",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postMove(new Request("http://localhost/api/documents/move", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", parentId: "parent_1", position: 2.7, workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "move",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
workspaceId: "ws_1",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.subtree.move");
});
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_rename_1",
traceId: "trace_tree_rename_1",
result: {
action: "rename",
workspaceId: "ws_1",
documentId: "doc_1",
title: "新标题",
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename");
});
it("creates child route delegates to unified adapter", async () => {
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
method: "POST",
@@ -146,10 +293,15 @@ describe("documents route adapters", () => {
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route delegates to unified page write adapter", async () => {
it("title route 在 page head 请求下仍委托 unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
body: JSON.stringify({
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
commandName: "page.head.updateTitle",
}),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
@@ -17,15 +17,60 @@ interface RenamePayload {
documentId: string;
workspaceId?: string | null;
title: string;
commandName?: string | null;
}
type TreeRenameResponse = {
requestId?: string;
traceId?: string;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { documentId, workspaceId, title }: RenamePayload = await request.json();
const { documentId, workspaceId, title, commandName }: RenamePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedTitle = assertTitle(title);
const normalizedWorkspaceId = workspaceId?.trim() || null;
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "rename",
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
title: normalizedTitle,
}),
});
const payload = (await response.json().catch(() => null)) as TreeRenameResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "重命名失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.rename",
},
});
}
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: PAGE_COMMAND_NAMES.updateTitle,
@@ -5,6 +5,11 @@ const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockResolveKernelFileTreeProjection = vi.fn();
const mockAttachKernelFileTreeProjection = vi.fn((input: { dataset: unknown; projection: unknown }) => ({
...(input.dataset as Record<string, unknown>),
kernel_file_tree_projection: input.projection,
}));
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
@@ -29,6 +34,11 @@ vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
}));
vi.mock("@/lib/server/kernel-file-tree", () => ({
resolveKernelFileTreeProjection: (...args: unknown[]) => mockResolveKernelFileTreeProjection(...args),
attachKernelFileTreeProjection: (...args: unknown[]) => mockAttachKernelFileTreeProjection(...args),
}));
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockGetAuthedConvexClient.mockReset();
@@ -36,11 +46,18 @@ describe("/api/mnote-web/stream route", () => {
mockBuildDocumentQueryEnvelope.mockReset();
mockExecuteRustBridgeQueryTransport.mockReset();
mockResolveRustBridgeQueryPlan.mockReset();
mockResolveKernelFileTreeProjection.mockReset();
mockAttachKernelFileTreeProjection.mockClear();
mockDocumentBridgeErrorResponse.mockClear();
});
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client: { query: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
@@ -107,6 +124,13 @@ describe("/api/mnote-web/stream route", () => {
has_more: false,
generated_at: "2026-04-22T00:00:00Z",
});
mockResolveKernelFileTreeProjection.mockResolvedValue({
projectionId: "kernel_projection:file_tree:root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
});
const { GET } = await import("./route");
const response = await GET(
@@ -124,7 +148,103 @@ describe("/api/mnote-web/stream route", () => {
expect(text).toContain('"projection":"sidebar_tree"');
expect(text).toContain('"workspaceId":"ws_1"');
expect(text).toContain('"activeWorkspaceId":"ws_1"');
expect(text).toContain('"kernelFileTreeProjection"');
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledTimes(1);
});
it("应把请求 cursor 继续透传到 overview query 和 snapshot envelope", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client: { query: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_2",
traceId: "trace_stream_2",
workspaceId: "ws_1",
});
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: "evt_9",
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
mockResolveRustBridgeQueryPlan
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
.mockResolvedValueOnce({
kind: "query",
functionName: "bridgeLogs:listWorkspaceOverview",
argsJson: { workspaceId: "ws_1", cursor: "evt_9" },
});
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
active_workspace_id: "ws_1",
workspaces: [],
documents: [],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
})
.mockResolvedValueOnce({
workspace_id: "ws_1",
command_logs: [],
domain_events: [],
counts: { command_logs: 0, domain_events: 0 },
filters: null,
next_cursor: "evt_10",
has_more: true,
generated_at: "2026-04-22T00:00:00Z",
});
mockResolveKernelFileTreeProjection.mockResolvedValue({
projectionId: "kernel_projection:file_tree:root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
});
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9", {
method: "GET",
}),
);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain('"cursor":"evt_9"');
expect(mockBuildDocumentQueryEnvelope).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
payload: expect.objectContaining({
workspaceId: "ws_1",
cursor: "evt_9",
}),
}),
);
});
});
@@ -9,6 +9,10 @@ import {
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
import {
attachKernelFileTreeProjection,
resolveKernelFileTreeProjection,
} from "@/lib/server/kernel-file-tree";
export const dynamic = "force-dynamic";
@@ -26,7 +30,7 @@ export async function GET(request: Request) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
@@ -46,6 +50,20 @@ export async function GET(request: Request) {
client,
plan: sidebarPlan,
});
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
dataset: sidebarDataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
dataset: sidebarDataset,
}),
});
const overviewEnvelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
@@ -79,10 +97,13 @@ export async function GET(request: Request) {
cursor,
requestId: context.requestId,
traceId: context.traceId,
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
snapshot: {
dataset: sidebarDataset,
tree: sidebarDataset.kernel_sidebar_projection ?? sidebarDataset.kernelSidebarProjection ?? null,
dataset: sidebarDatasetWithFileTree,
tree:
sidebarDatasetWithFileTree.kernel_sidebar_projection ??
sidebarDatasetWithFileTree.kernelSidebarProjection ??
null,
},
overview,
};
+19 -1
View File
@@ -16,6 +16,10 @@ import {
mapSidebarDatasetListQueryResultToInitialData,
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import {
attachKernelFileTreeProjection,
resolveKernelFileTreeProjection,
} from "@/lib/server/kernel-file-tree";
export const dynamic = "force-dynamic";
@@ -54,7 +58,21 @@ export async function GET(request: Request) {
client,
plan,
});
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDataset);
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
dataset: sidebarDataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId: targetWorkspaceId,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
dataset: sidebarDataset,
}),
});
const result = mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree);
return NextResponse.json({
...result,
@@ -0,0 +1,374 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
const mockEnsureDocumentScaffold = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
documents: {
getMeta: "documents:getMeta",
},
workspaces: {
ensureDefaultWorkspace: "workspaces:ensureDefaultWorkspace",
},
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/documents/bridge", () => ({
assertDocumentId: (value: string | null | undefined) => {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) {
throw new Error("缺少 documentId");
}
return normalized;
},
assertTitle: (value: string | null | undefined) => {
const normalized = typeof value === "string" ? value.trim() : "";
if (!normalized) {
throw new Error("缺少标题");
}
return normalized;
},
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
}));
vi.mock("@/lib/documents/page-lifecycle-side-effects", () => ({
ensureDocumentScaffold: (...args: unknown[]) => mockEnsureDocumentScaffold(...args),
}));
describe("/api/tree/commands route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockExecuteRustBridgeMutationTransport.mockReset();
mockEnsureDocumentScaffold.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("create action 走 tree.node.create,并保留本地 scaffold 副作用", async () => {
const client = {
mutation: vi.fn(async () => ({ activeWorkspaceId: "ws_root" })),
query: vi.fn(),
};
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client,
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_tree_1",
traceId: "trace_tree_1",
workspaceId: "ws_root",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.node.create",
commandId: "cmd_tree_create_1",
functionName: "documents:createWithParentReference",
workspaceId: "ws_root",
requestId: "req_tree_1",
traceId: "trace_tree_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
id: "doc_new",
title: "无标题",
parent_id: null,
sort_order: 0,
workspace_id: "ws_root",
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "create",
parentId: null,
}),
}),
);
expect(response.status).toBe(200);
const payload = await response.json() as {
result: {
action: string;
documentId: string;
workspaceId: string;
title: string;
};
};
expect(payload.result.action).toBe("create");
expect(payload.result.documentId).toBe("doc_new");
expect(payload.result.workspaceId).toBe("ws_root");
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.node.create",
payload: expect.objectContaining({
workspaceId: "ws_root",
parentId: null,
title: "无标题",
accessScope: "private",
}),
}),
);
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
});
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async () => ({
id: "doc_1",
workspace_id: "ws_1",
})),
};
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client,
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_tree_2",
traceId: "trace_tree_2",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_tree_move_1",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_tree_2",
traceId: "trace_tree_2",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
updated_at: "2026-04-23T00:00:00Z",
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "move",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2.9,
workspaceId: "ws_1",
}),
}),
);
expect(response.status).toBe(200);
const payload = await response.json() as {
result: {
action: string;
documentId: string;
parentId: string | null;
sortOrder: number | null;
};
};
expect(payload.result).toMatchObject({
action: "move",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
workspaceId: "ws_1",
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.subtree.move",
payload: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
},
}),
);
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
});
it("rename action 走 tree.node.rename,并保留标题 payload", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async () => ({
id: "doc_1",
workspace_id: "ws_1",
})),
};
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user_1",
email: "dev@example.com",
name: "开发用户",
},
client,
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_tree_3",
traceId: "trace_tree_3",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.node.rename",
commandId: "cmd_tree_rename_1",
functionName: "documents:updateTitle",
workspaceId: "ws_1",
requestId: "req_tree_3",
traceId: "trace_tree_3",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
updated_at: "2026-04-23T00:00:00Z",
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
}),
}),
);
expect(response.status).toBe(200);
const payload = await response.json() as {
result: {
action: string;
documentId: string;
workspaceId: string;
title: string;
};
};
expect(payload.result).toMatchObject({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.node.rename",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
},
}),
);
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,259 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
assertDocumentId,
assertTitle,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
type TreeCommandPayload = {
action?: "create" | "move" | "rename";
workspaceId?: string | null;
documentId?: string | null;
parentId?: string | null;
title?: string | null;
accessScope?: "private" | "shared" | "public" | null;
content?: unknown;
sortOrder?: number | null;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeTitle(value: unknown) {
const title = typeof value === "string" ? value.trim() : "";
return title || "无标题";
}
function normalizeSortOrder(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as TreeCommandPayload;
switch (payload.action) {
case "create":
return handleCreate(request, payload);
case "move":
return handleMove(request, payload);
case "rename":
return handleRename(request, payload);
default:
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
}
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
async function handleCreate(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const parentId = trimOrNull(payload.parentId);
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(),
});
let workspaceId: string | null = trimOrNull(payload.workspaceId);
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = trimOrNull(parentDoc.workspace_id);
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else if (!workspaceId) {
workspaceId = trimOrNull(workspaceBootstrap.activeWorkspaceId);
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
const title = normalizeTitle(payload.title);
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.node.create",
payload: {
documentId,
workspaceId,
parentId,
title,
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
content: Array.isArray(payload.content) ? payload.content : [],
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route create",
refs: ["next-tree-route"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{
id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
workspace_id: string;
access_scope: "private" | "shared" | "public";
is_template: boolean;
created_at: string;
updated_at: string;
}>({
client,
plan,
});
await ensureDocumentScaffold(result.id, result.title ?? title);
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "create",
workspaceId,
documentId: result.id,
parentId: result.parent_id ?? parentId,
title: result.title ?? title,
sortOrder: result.sort_order ?? null,
updatedAt: result.updated_at ?? null,
execution: result,
},
});
}
async function handleMove(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const parentId = trimOrNull(payload.parentId);
const sortOrder = normalizeSortOrder(payload.sortOrder);
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.subtree.move",
payload: {
documentId,
parentId,
sortOrder,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route move",
refs: ["next-tree-route"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
client,
plan,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "move",
workspaceId,
documentId,
parentId,
sortOrder,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
},
});
}
async function handleRename(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const title = assertTitle(payload.title ?? null);
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.node.rename",
payload: {
documentId,
workspaceId,
title,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route rename",
refs: ["next-tree-route"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
client,
plan,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "rename",
workspaceId,
documentId,
title,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
},
});
}
export const runtime = "nodejs";
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockResolveMnoteWebInternalUrl = vi.fn();
const mockBuildForwardHeaders = vi.fn();
const mockFetch = vi.fn();
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
}));
describe("/api/tree/shell route", () => {
beforeEach(() => {
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response("<!doctype html><html><body>tree shell</body></html>", {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"x-upstream": "mnote-web-tree",
},
}),
);
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
{
method: "GET",
headers: {
cookie: "session=abc",
},
},
),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
expect.objectContaining({
method: "GET",
headers: expect.any(Headers),
cache: "no-store",
redirect: "manual",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("cookie")).toBe("session=abc");
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
expect(fetchHeaders.get("accept")).toContain("text/html");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_shell_proxy");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("connection")).toBeNull();
expect(response.headers.get("x-upstream")).toBe("mnote-web-tree");
expect(await response.text()).toContain("tree shell");
});
});
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
const hopByHopHeaders = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
];
hopByHopHeaders.forEach((name) => headers.delete(name));
};
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("accept", "text/html,application/xhtml+xml");
headers.set("x-mnote-source-channel", "next_tree_shell_proxy");
headers.set("x-mnote-source-client", "wolai-frontend");
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
headers.set("x-mnote-workspace-id", workspaceId);
}
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "manual",
});
const body = await upstream.arrayBuffer();
const responseHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(responseHeaders);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new NextResponse(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
const message = error instanceof Error ? error.message : "tree shell 代理失败";
return NextResponse.json(
{
error: message,
},
{ status: 502 },
);
}
}