4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
@@ -1,23 +1,5 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCopyTreePayload,
} from "@/lib/documents/page-command-adapter";
import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
export const dynamic = "force-dynamic";
type CopyTreeItem = {
documentId: string;
@@ -25,102 +7,88 @@ type CopyTreeItem = {
};
type CopyTreePayload = {
items: CopyTreeItem[];
targetParentId: string | null;
items?: CopyTreeItem[] | null;
targetParentId?: string | null;
};
type TreeCopyResponse = {
requestId?: string;
traceId?: string;
result?: {
items?: Array<{
oldId: string;
newId: string;
}>;
} | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { client } = await getAuthedConvexClient();
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: assertDocumentId(item.documentId),
recursive: Boolean(item.recursive),
}));
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const normalizedTargetParentId = payload.targetParentId?.trim() || null;
let workspaceId: string | null = null;
if (normalizedTargetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: normalizedTargetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id?.trim() || null;
} else {
const firstDoc = await client.query(api.documents.getMeta, { id: normalizedItems[0].documentId });
if (!firstDoc) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
workspaceId = firstDoc.workspace_id?.trim() || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCopyTreePayload,
{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
}>;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
workspaceId,
targetParentId: normalizedTargetParentId,
items: normalizedItems,
},
context: bridgeContext,
target: {
workspaceId,
pageId: normalizedTargetParentId,
},
}),
});
await Promise.all(
result.result.items.map(async (item) => {
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
return NextResponse.json({
items: result.result.items.map((item) => ({
oldId: item.oldId,
newId: item.newId,
})),
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 payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: item.documentId.trim(),
recursive: Boolean(item.recursive),
}))
.filter((item) => item.documentId.length > 0);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { 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: "copy",
targetParentId:
typeof payload.targetParentId === "string" ? payload.targetParentId.trim() || null : null,
items: normalizedItems,
}),
});
const result = (await response.json().catch(() => null)) as
| TreeCopyResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
result && typeof result === "object" && "error" in result && typeof result.error === "string"
? result.error
: "复制页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
items: result?.result?.items ?? [],
meta: {
requestId: result?.requestId,
traceId: result?.traceId,
commandName: "tree.subtree.copy",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "复制页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,59 +1,75 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentDeletePayload,
} from "@/lib/documents/page-command-adapter";
type TreeArchiveResponse = {
requestId?: string;
traceId?: string;
};
type DeletePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentDeletePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.delete",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: 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, workspaceId }: DeletePayload = 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: "archive",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeArchiveResponse
| { 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({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.archive",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,11 +1,74 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { executeDocumentEmbedBridgeCommand } from "@/lib/documents/page-command-adapter";
type TreeEmbedResponse = {
requestId?: string;
traceId?: string;
};
type EmbedPayload = {
sourceId?: string | null;
targetId?: string | null;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
return executeDocumentEmbedBridgeCommand(request);
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { sourceId, targetId }: EmbedPayload = await request.json();
const normalizedSourceId = typeof sourceId === "string" ? sourceId.trim() : "";
const normalizedTargetId = typeof targetId === "string" ? targetId.trim() : "";
if (!normalizedSourceId || !normalizedTargetId) {
return NextResponse.json({ error: "缺少 sourceId 或 targetId" }, { 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: "embed",
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeEmbedResponse
| { 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.embed",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "嵌入失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,13 +1,77 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { executeDocumentPurgeBridgeCommand } from "@/lib/documents/page-command-adapter";
type TreePurgeResponse = {
requestId?: string;
traceId?: string;
result?: {
purged?: boolean;
} | null;
};
type PurgePayload = {
documentId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
return executeDocumentPurgeBridgeCommand(request);
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId }: PurgePayload = 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: "purge",
documentId: normalizedDocumentId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreePurgeResponse
| { 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({
success: true,
purged: payload?.result?.purged ?? true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.purge",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "彻底删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,59 +1,75 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentRestorePayload,
} from "@/lib/documents/page-command-adapter";
type TreeRestoreResponse = {
requestId?: string;
traceId?: string;
};
type RestorePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = (await request.json()) as { documentId?: string; workspaceId?: string | null };
const normalizedDocumentId = assertDocumentId(body.documentId);
const normalizedWorkspaceId = body.workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentRestorePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.restore",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
success: 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, workspaceId }: RestorePayload = 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: "restore",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeRestoreResponse
| { 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({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.restore",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "恢复失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -72,22 +72,23 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postDelete } from "@/app/api/documents/delete/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postRestore } from "@/app/api/documents/restore/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 postCopyTree } from "@/app/api/documents/copy-tree/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,
executeDocumentEmbedBridgeCommand,
executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
@@ -246,12 +247,115 @@ describe("documents route adapters", () => {
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
});
it("embed route delegates to unified adapter", async () => {
await postEmbed(new Request("http://localhost/api/documents/embed", {
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_archive_1",
traceId: "trace_tree_archive_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "archive",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.archive");
});
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_restore_1",
traceId: "trace_tree_restore_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "restore",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.restore");
});
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_embed_1",
traceId: "trace_tree_embed_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
}));
expect(executeDocumentEmbedBridgeCommand).toHaveBeenCalled();
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: "embed",
sourceId: "doc_1",
targetId: "doc_2",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.embed");
});
it("template route delegates to unified adapter", async () => {
@@ -270,12 +374,85 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
});
it("purge route delegates to unified adapter", async () => {
await postPurge(new Request("http://localhost/api/documents/purge", {
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_purge_1",
traceId: "trace_tree_purge_1",
result: {
purged: true,
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1" }),
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
const payload = await response.json() as {
success: boolean;
purged: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "purge",
documentId: "doc_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.purged).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.purge");
});
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_copy_1",
traceId: "trace_tree_copy_1",
result: {
items: [{ oldId: "doc_1", newId: "doc_2" }],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
}));
const payload = await response.json() as {
items: Array<{ oldId: string; newId: string }>;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "copy",
targetParentId: null,
items: [{ documentId: "doc_1", recursive: true }],
}),
}),
);
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
expect(payload.meta.commandName).toBe("tree.subtree.copy");
});
it("page route delegates to unified aggregate loader", async () => {
@@ -1,201 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentBridgeContextWithActor = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQuery = 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 mockStreamTreeFrames = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {},
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
mockBuildDocumentBridgeContextWithActor(...args),
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
executeRustBridgeQueryTransport: (...args: unknown[]) => mockExecuteRustBridgeQueryTransport(...args),
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args),
}));
vi.mock("@/lib/server/kernel-file-tree", () => ({
resolveKernelFileTreeProjection: (...args: unknown[]) => mockResolveKernelFileTreeProjection(...args),
attachKernelFileTreeProjection: (...args: unknown[]) => mockAttachKernelFileTreeProjection(...args),
vi.mock("@/lib/server/kernel-file-tree", async () => {
const actual = await vi.importActual<typeof import("@/lib/server/kernel-file-tree")>(
"@/lib/server/kernel-file-tree",
);
return {
...actual,
resolveKernelFileTreeProjection: (...args: unknown[]) =>
mockResolveKernelFileTreeProjection(...args),
};
});
vi.mock("@/lib/tree-stream/server", () => ({
streamTreeFrames: (...args: unknown[]) => mockStreamTreeFrames(...args),
}));
async function* makeFrames() {
yield {
event: "snapshot",
payload: {
kind: "snapshot",
stream: "workspace",
workspaceId: "ws_1",
rootNodeId: null,
cursor: "cursor_1",
projection: "sidebar_tree",
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] } },
overview: { command_logs: [], domain_events: [] },
},
};
}
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockGetAuthedConvexClient.mockReset();
mockBuildDocumentBridgeContext.mockReset();
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() },
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
auth: { userId: "user_1" },
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_1",
traceId: "trace_stream_1",
workspaceId: "ws_1",
mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
requestId: "req_1",
traceId: "trace_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
});
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: null,
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" } });
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
active_workspace_id: "ws_1",
workspaces: [],
documents: [
{
id: "page_root",
workspace_id: "ws_1",
title: "工作区首页",
parent_id: null,
sort_order: 0,
is_starred: true,
is_template: false,
created_at: "2026-04-22T00:00:00Z",
updated_at: "2026-04-22T00:00:00Z",
},
],
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: null,
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: [],
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
argsJson: { workspaceId: "ws_1" },
functionName: "bridgeLogs:listWorkspaceOverview",
});
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
method: "GET",
headers: { cookie: "a=1" },
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
const text = await response.text();
expect(text).toContain("event: snapshot");
expect(text).toContain('"kind":"snapshot"');
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({
mockExecuteRustBridgeQuery.mockReset();
mockExecuteRustBridgeQueryTransport.mockImplementation(async ({ plan }) => {
if (plan?.functionName === "bridgeLogs:listWorkspaceOverview") {
return {
command_logs: [],
domain_events: [],
};
}
return {
active_workspace_id: "ws_1",
workspaces: [],
documents: [],
@@ -208,43 +107,59 @@ describe("/api/mnote-web/stream route", () => {
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",
};
});
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
projectionId: "kernel_projection:file_tree:workspace_root",
projection: "file_tree",
rootNodeId: null,
items: [],
edges: [],
});
mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames());
});
it("应在 3000 route 内直接生成 SSE,不再代理 mnote-web:3104", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
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", {
new Request(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9&rootNodeId=page_root&pollMs=500&maxPolls=0",
{ method: "GET" },
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.text()).toContain("event: snapshot");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
expect(mockStreamTreeFrames).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
initialCursor: "evt_9",
pollMs: 500,
maxPolls: 0,
}),
);
});
it("Convex 未启用时应返回 501,而不是探测 3104", async () => {
mockIsConvexEnabled.mockReturnValue(false);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
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",
}),
}),
);
expect(response.status).toBe(501);
expect(fetchSpy).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
});
});
@@ -1,76 +1,72 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentBridgeContextWithActor,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
import {
attachKernelFileTreeProjection,
resolveKernelFileTreeProjection,
} from "@/lib/server/kernel-file-tree";
streamTreeFrames,
type TreeStreamOverview,
type TreeStreamSnapshotPayload,
} from "@/lib/tree-stream/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function toSseFrame(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function encodeSseFrame(event: string, payload: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const workspaceId = String(requestUrl.searchParams.get("workspaceId") || "").trim();
const cursor = String(requestUrl.searchParams.get("cursor") || "").trim() || null;
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
if (!workspaceId) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const requestUrl = new URL(request.url);
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const { auth, client } = await getAuthedConvexClient();
const actor = {
actorType: "user",
actorId: auth.userId,
sessionId: null,
};
const context = buildDocumentBridgeContextWithActor({
request,
actor,
workspaceId,
source: {
channel: "next_mnote_web_stream",
client: "wolai-frontend",
},
});
const sidebarEnvelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const sidebarPlan = await resolveRustBridgeQueryPlan({
context,
envelope: sidebarEnvelope,
});
const sidebarDataset = await executeRustBridgeQueryTransport({
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({
const loadOverview = async (): Promise<TreeStreamOverview> => {
const envelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 20,
cursor,
limit: 50,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
@@ -79,43 +75,88 @@ export async function GET(request: Request) {
aggregateId: null,
},
});
const overviewPlan = await resolveRustBridgeQueryPlan({
const plan = await resolveRustBridgeQueryPlan({
context,
envelope: overviewEnvelope,
envelope,
});
const overview = await executeRustBridgeQueryTransport({
return executeRustBridgeQueryTransport<TreeStreamOverview>({
client,
plan: overviewPlan,
plan,
});
};
const payload = {
kind: "snapshot",
stream: "workspace",
projection: "sidebar_tree",
workspaceId,
rootNodeId: null,
cursor,
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const datasetWithFileTree = attachKernelFileTreeProjection({
dataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor,
dataset,
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(requestUrl, "depth"),
}),
});
return {
requestId: context.requestId,
traceId: context.traceId,
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
data: datasetWithFileTree,
snapshot: {
dataset: sidebarDatasetWithFileTree,
tree:
sidebarDatasetWithFileTree.kernel_sidebar_projection ??
sidebarDatasetWithFileTree.kernelSidebarProjection ??
null,
dataset: datasetWithFileTree,
},
overview,
};
};
return new Response(toSseFrame("snapshot", payload), {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const frame of streamTreeFrames({
workspaceId,
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
initialCursor: requestUrl.searchParams.get("cursor"),
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
maxPolls: readNumberParam(requestUrl, "maxPolls"),
loadOverview,
loadSnapshot,
})) {
if (request.signal.aborted) {
break;
}
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
}
controller.close();
} catch (error) {
controller.error(error);
}
},
cancel() {
return undefined;
},
});
return new NextResponse(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream",
},
});
}
File diff suppressed because it is too large Load Diff
+616 -69
View File
@@ -1,8 +1,10 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import type { Json } from "@/types/supabase";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
assertDocumentId,
assertTitle,
@@ -10,21 +12,49 @@ import {
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import {
copyMindmapFilesIfExists,
ensureDocumentScaffold,
} from "@/lib/documents/page-lifecycle-side-effects";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
type TreeCommandAction =
| "create"
| "rename"
| "move"
| "archive"
| "restore"
| "purge"
| "embed"
| "copy";
type TreeCopyItem = {
documentId: string;
recursive: boolean;
};
type TreeCommandPayload = {
action?: "create" | "move" | "rename";
action?: TreeCommandAction;
workspaceId?: string | null;
documentId?: string | null;
parentId?: string | null;
targetParentId?: string | null;
title?: string | null;
accessScope?: "private" | "shared" | "public" | null;
content?: unknown;
sortOrder?: number | null;
sourceId?: string | null;
targetId?: string | null;
items?: TreeCopyItem[] | null;
};
function trimOrNull(value: unknown) {
@@ -42,6 +72,150 @@ function normalizeSortOrder(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
if (!streamDelta) {
return commandPayload;
}
if (isRecord(commandPayload)) {
return {
...commandPayload,
streamDelta,
};
}
return {
payload: commandPayload,
streamDelta,
};
}
async function recordTreeCommandSuccess(args: {
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
commandPayload?: unknown;
}) {
try {
await recordBridgeCommandArtifacts({
context: args.context,
envelope: args.envelope,
client: args.client,
commandPayload: args.commandPayload,
});
} catch (error) {
console.warn("[tree.commands] bridge success artifacts skipped:", error);
}
}
async function loadTreeCommandSidebarSnapshot(args: {
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
auth: Awaited<ReturnType<typeof getAuthedConvexClient>>["auth"];
workspaceId: string | null;
}) {
if (!args.workspaceId) {
return null;
}
try {
const result = await loadSidebarDataFromConvex({
client: args.client,
auth: {
userId: args.auth.userId,
email: args.auth.email,
name: args.auth.name,
},
fallbackName: args.auth.email ?? args.auth.name ?? "我的空间",
requestedWorkspaceId: args.workspaceId,
});
return result.sidebarInitialData ?? null;
} catch (error) {
console.warn("[tree.commands] sidebar snapshot skipped:", error);
return null;
}
}
function buildTreeCommandSnapshotDelta(
sidebarSnapshot: unknown,
): Record<string, unknown> | null {
if (!isRecord(sidebarSnapshot)) {
return null;
}
if (Array.isArray(sidebarSnapshot.documents)) {
return {
op: "replace_documents",
documents: sidebarSnapshot.documents,
};
}
return {
op: "replace_sidebar",
sidebar: sidebarSnapshot,
};
}
function buildTreeMovePreflightDataFromSidebarSnapshot(
sidebarSnapshot: unknown,
): Record<string, unknown> | null {
if (!isRecord(sidebarSnapshot) || !Array.isArray(sidebarSnapshot.documents)) {
return null;
}
return {
documents: sidebarSnapshot.documents,
};
}
async function resolveTreeMutationResult<TResult>(args: {
request: Request;
workspaceId: string | null;
commandName: string;
payload: unknown;
preflightData?: Record<string, unknown> | null;
pageId?: string | null;
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
}) {
const context = await buildDocumentBridgeContext({
request: args.request,
workspaceId: args.workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: args.commandName,
payload: args.payload,
context,
preflightData: args.preflightData ?? null,
target: {
workspaceId: args.workspaceId,
pageId: args.pageId ?? undefined,
},
reason: `tree-route ${args.commandName}`,
refs: ["next-tree-route"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
try {
const result = await executeRustBridgeMutationTransport<TResult>({
client: args.client,
plan,
});
return {
context,
envelope,
result,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context,
envelope,
client: args.client,
error,
});
throw error;
}
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -51,11 +225,21 @@ export async function POST(request: Request) {
const payload = (await request.json()) as TreeCommandPayload;
switch (payload.action) {
case "create":
return handleCreate(request, payload);
return await handleCreate(request, payload);
case "move":
return handleMove(request, payload);
return await handleMove(request, payload);
case "rename":
return handleRename(request, payload);
return await handleRename(request, payload);
case "archive":
return await handleArchive(request, payload);
case "restore":
return await handleRestore(request, payload);
case "purge":
return await handlePurge(request, payload);
case "embed":
return await handleEmbed(request, payload);
case "copy":
return await handleCopy(request, payload);
default:
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
}
@@ -91,33 +275,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
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<{
const { context, envelope, result } = await resolveTreeMutationResult<{
id: string;
title: string | null;
parent_id: string | null;
@@ -128,11 +286,42 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
created_at: string;
updated_at: string;
}>({
request,
workspaceId,
commandName: "tree.node.create",
payload: {
documentId,
workspaceId,
parentId,
title,
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
content: Array.isArray(payload.content) ? payload.content : [],
},
pageId: documentId,
client,
plan,
});
await ensureDocumentScaffold(result.id, result.title ?? title);
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: result.id,
workspace_id: result.workspace_id,
title: result.title ?? title,
parent_id: result.parent_id ?? parentId,
sort_order: result.sort_order ?? 0,
access_scope: result.access_scope,
is_starred: false,
is_template: result.is_template,
created_at: result.created_at,
updated_at: result.updated_at,
},
}),
});
return NextResponse.json({
requestId: context.requestId,
@@ -151,7 +340,7 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
}
async function handleMove(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
@@ -161,32 +350,44 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const parentId = trimOrNull(payload.parentId);
const sortOrder = normalizeSortOrder(payload.sortOrder);
const context = await buildDocumentBridgeContext({
request,
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.subtree.move",
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string | null;
updated_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.subtree.move",
payload: {
documentId,
parentId,
sortOrder,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route move",
refs: ["next-tree-route"],
preflightData: movePreflightData,
pageId: documentId,
client,
});
const plan = await resolveRustBridgeCommandPlan({
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
client,
plan,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
),
});
return NextResponse.json({
@@ -194,12 +395,23 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
traceId: context.traceId,
result: {
action: "move",
workspaceId,
workspaceId: trimOrNull(result?.workspace_id) ?? workspaceId,
documentId,
parentId,
sortOrder,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
parentId: trimOrNull(result?.parent_id) ?? parentId,
sortOrder:
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
? result.sort_order
: sortOrder,
updatedAt: trimOrNull(result?.updated_at) ?? null,
execution: {
...(result ?? null),
parent_id: trimOrNull(result?.parent_id) ?? parentId,
sort_order:
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
? result.sort_order
: sortOrder,
workspace_id: trimOrNull(result?.workspace_id) ?? workspaceId,
},
},
});
}
@@ -214,32 +426,33 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const title = assertTitle(payload.title ?? null);
const context = await buildDocumentBridgeContext({
const { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
updated_at?: string | null;
}>({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.node.rename",
commandName: "tree.node.rename",
payload: {
documentId,
workspaceId,
title,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "tree-route rename",
refs: ["next-tree-route"],
pageId: documentId,
client,
});
const plan = await resolveRustBridgeCommandPlan({
await recordTreeCommandSuccess({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ ok?: boolean; updated_at?: string | null }>({
client,
plan,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: documentId,
title,
updated_at: result?.updated_at ?? null,
},
}),
});
return NextResponse.json({
@@ -256,4 +469,338 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
});
}
async function handleArchive(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 { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
updated_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.archive",
payload: {
documentId,
workspaceId,
},
pageId: documentId,
client,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "archive",
workspaceId,
documentId,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
},
});
}
async function handleRestore(request: Request, payload: TreeCommandPayload) {
const { auth, 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 { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
updated_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.restore",
payload: {
documentId,
workspaceId,
},
pageId: documentId,
client,
});
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "restore",
workspaceId,
documentId,
updatedAt: result?.updated_at ?? null,
execution: result ?? null,
},
});
}
async function handlePurge(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 { context, envelope, result } = await resolveTreeMutationResult<{
ok?: boolean;
purged?: boolean;
purged_at?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.purge",
payload: {
documentId,
},
pageId: documentId,
client,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "purge",
workspaceId,
documentId,
purged: result?.purged ?? true,
updatedAt: result?.purged_at ?? null,
execution: result ?? null,
},
});
}
async function handleEmbed(request: Request, payload: TreeCommandPayload) {
const { client } = await getAuthedConvexClient();
const sourceId = assertDocumentId(payload.sourceId ?? null);
const targetId = assertDocumentId(payload.targetId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
if (!sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const targetContent = await client.query(api.documents.getContent, { id: targetId });
if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull(
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
);
const anchorIndex = anchorId
? currentBlocks.findIndex(
(block) =>
typeof block === "object" &&
block !== null &&
String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
props: {
pageId: sourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId =
trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
revision?: number | null;
conflict_detection_key?: string | null;
}>({
request,
workspaceId,
commandName: "tree.node.embed",
payload: {
...buildDocumentSavePayload({
documentId: targetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: nextContent,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
}),
sourceDocumentId: sourceId,
targetDocumentId: targetId,
anchorBlockId: anchorId,
},
pageId: targetId,
client,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "noop",
}),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "embed",
workspaceId,
documentId: targetId,
sourceDocumentId: sourceId,
targetDocumentId: targetId,
execution: result ?? null,
},
});
}
async function handleCopy(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: assertDocumentId(item.documentId),
recursive: Boolean(item.recursive),
}));
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = trimOrNull(payload.targetParentId);
let workspaceId: string | null = null;
if (targetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = trimOrNull(targetDoc.workspace_id);
} else {
const firstDoc = await client.query(api.documents.getMeta, {
id: normalizedItems[0]?.documentId ?? "",
});
if (!firstDoc) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
workspaceId = trimOrNull(firstDoc.workspace_id);
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const { context, envelope, result } = await resolveTreeMutationResult<{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
}>;
}>({
request,
workspaceId,
commandName: "tree.subtree.copy",
payload: {
workspaceId,
targetParentId,
items: normalizedItems,
},
pageId: targetParentId,
client,
});
await Promise.all(
(result.items ?? []).map(async (item) => {
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
result: {
action: "copy",
workspaceId,
targetParentId,
items: (result.items ?? []).map((item) => ({
oldId: item.oldId,
newId: item.newId,
})),
execution: result ?? null,
},
});
}
export const runtime = "nodejs";