4-10 树域 Rust 家族化
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user