4-26 树rust-2
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user