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
@@ -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 },
);
}
}