4-10 树域 Rust 家族化

This commit is contained in:
lix-2026
2026-04-24 06:10:18 +08:00
parent 41e958769e
commit 94631f3636
49 changed files with 5751 additions and 557 deletions
@@ -1,119 +1,83 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentCreatePayload,
} from "@/lib/documents/page-command-adapter";
import { ensureDocumentScaffold } from "@/lib/documents/page-lifecycle-side-effects";
type TreeCreateResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
execution?: {
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} | null;
} | null;
};
export async function POST(request: Request) {
try {
if (isConvexEnabled()) {
return await handleCreateRequestConvex(request);
}
return await handleCreateRequest();
} catch (error) {
console.error("创建页面失败", error);
const message = error instanceof Error ? error.message : "创建页面失败,请稍后再试";
return NextResponse.json({ error: message }, { status: 500 });
}
}
async function handleCreateRequestConvex(request: Request) {
const { auth, client } = await getAuthedConvexClient();
const { parentId }: { parentId?: string | null } = await request.json();
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(),
});
let workspaceId: string | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, {
id: parentId,
});
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const id = randomUUID();
const normalizedWorkspaceId = workspaceId.trim();
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<
DocumentCreatePayload,
{
id: string;
title?: string | null;
parent_id?: string | null;
sort_order?: number | null;
workspace_id?: string;
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
}
>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.create",
payload: {
documentId: id,
workspaceId: normalizedWorkspaceId,
parentId: parentId?.trim() || null,
title: "无标题",
accessScope,
content: [],
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: id,
},
const { parentId }: { parentId?: string | null } = await request.json();
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "create",
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
}),
});
await ensureDocumentScaffold(result.result.id, result.result.title ?? "无标题");
const payload = (await response.json().catch(() => null)) as TreeCreateResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "创建页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
...result.result,
id: payload?.result?.documentId ?? "",
title: payload?.result?.title ?? "无标题",
parent_id: payload?.result?.parentId ?? null,
sort_order: payload?.result?.sortOrder ?? null,
workspace_id: payload?.result?.workspaceId ?? undefined,
access_scope: payload?.result?.execution?.access_scope ?? "private",
is_template: payload?.result?.execution?.is_template ?? false,
created_at: payload?.result?.execution?.created_at ?? null,
updated_at:
payload?.result?.execution?.updated_at ??
payload?.result?.execution?.created_at ??
null,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.create",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "创建页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
async function handleCreateRequest() {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
export const runtime = "nodejs";
@@ -1,67 +1,79 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executePageLifecycleBridgeCommand,
type DocumentMovePayload,
} from "@/lib/documents/page-command-adapter";
interface MovePayload {
documentId: string;
type TreeMoveResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
sortOrder?: number | null;
} | null;
};
type MovePayload = {
documentId?: string | null;
parentId?: string | null;
position: number;
position?: number | null;
workspaceId?: string | null;
}
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedWorkspaceId = workspaceId?.trim() || null;
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: normalizedWorkspaceId,
});
const result = await executePageLifecycleBridgeCommand<DocumentMovePayload, { ok: boolean }>({
context: bridgeContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
parentId: parentId?.trim() || null,
sortOrder,
},
context: bridgeContext,
target: {
workspaceId: normalizedWorkspaceId,
pageId: normalizedDocumentId,
},
}),
});
return NextResponse.json({
ok: true,
meta: {
requestId: result.requestId,
traceId: result.traceId,
commandId: result.commandId,
commandName: result.commandName,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "move",
documentId: normalizedDocumentId,
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
sortOrder: typeof position === "number" && Number.isFinite(position) ? Math.floor(position) : 0,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as TreeMoveResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "移动失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.subtree.move",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "移动失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -79,6 +79,8 @@ import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { POST as postCreate } from "@/app/api/documents/create/route";
import { POST as postMove } from "@/app/api/documents/move/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
@@ -91,6 +93,151 @@ import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-comman
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
describe("documents route adapters", () => {
it("create route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_create_1",
traceId: "trace_tree_create_1",
result: {
action: "create",
workspaceId: "ws_1",
documentId: "doc_new",
parentId: null,
title: "无标题",
sortOrder: 0,
updatedAt: "2026-04-23T00:00:00Z",
execution: {
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCreate(new Request("http://localhost/api/documents/create", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parentId: null }),
}));
const payload = await response.json() as {
id: string;
workspace_id: string;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "create",
parentId: null,
}),
}),
);
expect(payload.id).toBe("doc_new");
expect(payload.workspace_id).toBe("ws_1");
expect(payload.meta.commandName).toBe("tree.node.create");
});
it("move route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_move_1",
traceId: "trace_tree_move_1",
result: {
action: "move",
workspaceId: "ws_1",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postMove(new Request("http://localhost/api/documents/move", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", parentId: "parent_1", position: 2.7, workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "move",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
workspaceId: "ws_1",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.subtree.move");
});
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_rename_1",
traceId: "trace_tree_rename_1",
result: {
action: "rename",
workspaceId: "ws_1",
documentId: "doc_1",
title: "新标题",
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename");
});
it("creates child route delegates to unified adapter", async () => {
await postCreateChild(new Request("http://localhost/api/documents/create-child", {
method: "POST",
@@ -146,10 +293,15 @@ describe("documents route adapters", () => {
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route delegates to unified page write adapter", async () => {
it("title route 在 page head 请求下仍委托 unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
body: JSON.stringify({
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
commandName: "page.head.updateTitle",
}),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
@@ -17,15 +17,60 @@ interface RenamePayload {
documentId: string;
workspaceId?: string | null;
title: string;
commandName?: string | null;
}
type TreeRenameResponse = {
requestId?: string;
traceId?: string;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const { documentId, workspaceId, title }: RenamePayload = await request.json();
const { documentId, workspaceId, title, commandName }: RenamePayload = await request.json();
const normalizedDocumentId = assertDocumentId(documentId);
const normalizedTitle = assertTitle(title);
const normalizedWorkspaceId = workspaceId?.trim() || null;
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "rename",
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
title: normalizedTitle,
}),
});
const payload = (await response.json().catch(() => null)) as TreeRenameResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "重命名失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.rename",
},
});
}
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: PAGE_COMMAND_NAMES.updateTitle,