feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockMaterializeRustTreeStreamDelta = vi.fn();
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
const mockReadRustTreeDomainEventType = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
listByIds: "mediaAssets:listByIds",
patchById: "mediaAssets:patchById",
},
documents: {
getMeta: "documents:getMeta",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) =>
mockExecuteRustBridgeMutationTransport(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
materializeRustTreeStreamDelta: (...args: unknown[]) =>
mockMaterializeRustTreeStreamDelta(...args),
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
mockMaterializeRustTreeDomainEventPlan(...args),
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
}));
vi.mock("@/lib/url/proxyForBrowser", () => ({
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
}));
describe("/api/media/batch route", () => {
beforeEach(() => {
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({
userId: "user_1",
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_asset_1",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.move",
commandId: "cmd_asset_1",
functionName: "mediaAssets:batchMove",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.moved",
},
domainEventHint: {
family: "tree",
eventType: "tree.resource.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({
items: [
{
id: "asset_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/file.pdf",
thumbnail_url: "/file.pdf",
file_name: "file.pdf",
file_size: 1024,
mime_type: "application/pdf",
ocr_text: null,
ocr_status: null,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
},
],
});
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
op: "upsert_assets",
upsertAssets: [{ id: "asset_1" }],
});
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.moved",
streamDelta: {
op: "upsert_assets",
upsertAssets: [{ id: "asset_1" }],
},
});
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.moved");
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("copy/move 应进入 Rust resource command plan 并记录资源 delta artifact", async () => {
const client = {
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:listByIds") {
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
return [{ id: "asset_1", workspace_id: "ws_1" }];
}
if (name === "documents:getMeta") {
expect(args).toEqual({ id: "doc_target" });
return { id: "doc_target", workspace_id: "ws_1" };
}
return null;
}),
mutation: vi.fn(),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/media/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
items: [
{
id: "asset_1",
document_id: "doc_target",
},
],
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.move",
payload: {
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
target: {
workspaceId: "ws_1",
pageId: "doc_target",
},
}),
);
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith(
expect.objectContaining({
client,
plan: expect.objectContaining({
functionName: "mediaAssets:batchMove",
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.resource.move",
}),
plan: expect.objectContaining({
commandName: "tree.resource.move",
functionName: "mediaAssets:batchMove",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ id: "asset_1" })],
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
+65 -94
View File
@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
@@ -19,41 +25,6 @@ interface BatchPayload {
targetSubPath?: string;
newName?: string;
}
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
const parseStoragePath = (fileUrl: string) => {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
return null;
} catch {
return null;
}
};
function resolveAssetLocation(asset: any): { bucket: string; path: string } | null {
if (asset?.storage_path) {
return { bucket: asset.bucket || BUCKET, path: asset.storage_path };
}
if (asset?.file_url) {
return parseStoragePath(asset.file_url);
}
return null;
}
function sanitizeSubPath(input: string | undefined): string {
const raw = typeof input === "string" ? input : "";
@@ -67,6 +38,14 @@ function sanitizeSubPath(input: string | undefined): string {
return cleaned.join("/");
}
function sanitizeTransferredAssetForBrowser(request: Request, asset: any) {
return {
...asset,
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
};
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -149,65 +128,57 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
const existing = (await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId: payload.targetDocumentId,
limit: 500,
})) as any[];
const existingNames = new Set<string>(
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
);
const results: any[] = [];
for (const asset of assets) {
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
if (!storageId) continue;
if (payload.action === "copy") {
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: newId,
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
asset_type: String(asset.asset_type ?? "file"),
file_name: fileName,
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
mime_type: (asset.mime_type ?? null) as any,
},
});
results.push(created);
} else {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: {
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
file_name: fileName,
},
});
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
}
const workspaceId = String(targetDoc.workspace_id ?? "").trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const safeItems = (results ?? []).map((a: any) => ({
...a,
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
}));
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const commandName =
payload.action === "copy" ? "tree.resource.copy" : "tree.resource.move";
const envelope = buildDocumentCommandEnvelope({
name: commandName,
payload: {
assetIds: payload.assetIds,
targetDocumentId: payload.targetDocumentId,
targetSubPath: sanitizeSubPath(payload.targetSubPath),
},
context,
target: {
workspaceId,
pageId: payload.targetDocumentId,
},
reason: `media-batch ${commandName}`,
refs: ["file-tree-resource-command"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ items?: any[] }>({
client: client as never,
plan,
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result,
});
} catch (error) {
console.warn("[media.batch] Rust bridge artifacts skipped:", error);
}
return NextResponse.json({ items: safeItems });
const safeItems = (result.items ?? []).map((item: any) =>
sanitizeTransferredAssetForBrowser(request, item),
);
return NextResponse.json({ items: safeItems });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockMaterializeRustTreeStreamDelta = vi.fn();
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
const mockReadRustTreeDomainEventType = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
generateUploadUrl: "mediaAssets:generateUploadUrl",
createWithStorage: "mediaAssets:createWithStorage",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
materializeRustTreeStreamDelta: (...args: unknown[]) =>
mockMaterializeRustTreeStreamDelta(...args),
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
mockMaterializeRustTreeDomainEventPlan(...args),
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
}));
vi.mock("@/lib/url/proxyForBrowser", () => ({
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
}));
describe("/api/media/upload route", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({
userId: "user_1",
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_upload_asset",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.upload",
commandId: "cmd_upload_asset",
functionName: "mediaAssets:createWithStorage",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.uploaded",
},
assetId: "asset_upload_1",
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: "demo.pdf",
fileSize: 7,
mimeType: "application/pdf",
assetType: "file",
resourceUploadPlan: {
action: "upload",
assetId: "asset_upload_1",
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: "demo.pdf",
fileSize: 7,
mimeType: "application/pdf",
assetType: "file",
},
},
});
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
op: "upsert_assets",
upsertAssets: [{ id: "asset_upload_1" }],
});
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.uploaded",
streamDelta: {
op: "upsert_assets",
upsertAssets: [{ id: "asset_upload_1" }],
},
});
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.uploaded");
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("上传应通过 tree.resource.upload plan 写入资源元数据并记录资源 delta artifact", async () => {
const createdAsset = {
id: "asset_upload_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/files/demo.pdf",
thumbnail_url: "/files/demo.pdf",
file_name: "demo.pdf",
file_size: 7,
mime_type: "application/pdf",
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const client = {
mutation: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:generateUploadUrl") {
expect(args).toEqual({ userId: "user_1" });
return "https://convex.test/upload";
}
if (name === "mediaAssets:createWithStorage") {
expect(args).toMatchObject({
userId: "user_1",
storageId: "storage_1",
targetSubPath: "mindmaps/mind_1",
resourceUploadPlan: {
action: "upload",
assetId: "asset_upload_1",
targetDocumentId: "doc_target",
},
asset: {
id: "asset_upload_1",
workspace_id: "ws_1",
document_id: "doc_target",
file_name: "demo.pdf",
},
});
return createdAsset;
}
return null;
}),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ storageId: "storage_1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const { POST } = await import("./route");
const formData = new FormData();
formData.append("file", new Blob(["content"], { type: "application/pdf" }), "demo.pdf");
formData.set("workspaceId", "ws_1");
formData.set("documentId", "doc_target");
formData.set("mindmapId", "mind_1");
const response = await POST(
new Request("http://localhost/api/media/upload", {
method: "POST",
body: formData,
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
asset: {
id: "asset_upload_1",
document_id: "doc_target",
},
mindmapUrl: "asset:asset_upload_1",
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.upload",
payload: expect.objectContaining({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: expect.any(String),
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.resource.upload",
}),
plan: expect.objectContaining({
commandName: "tree.resource.upload",
functionName: "mediaAssets:createWithStorage",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ id: "asset_upload_1" })],
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
@@ -1,18 +1,21 @@
import { NextResponse } from "next/server";
import type { MediaAsset } from "@/types/media";
import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
@@ -20,6 +23,26 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
return "file";
};
function isUploadFile(value: FormDataEntryValue | null): value is File {
return Boolean(
value &&
typeof value === "object" &&
(typeof File === "undefined" || value instanceof File || "arrayBuffer" in value) &&
typeof (value as File).arrayBuffer === "function" &&
typeof (value as File).name === "string",
);
}
function readOptionalStringArg(args: Record<string, unknown>, key: string): string | null {
const value = args[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readNumberArg(args: Record<string, unknown>, key: string): number | null {
const value = args[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -38,7 +61,7 @@ export async function POST(request: Request) {
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
if (!isUploadFile(file) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
@@ -51,6 +74,35 @@ export async function POST(request: Request) {
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || "");
const client = await getConvexAuthedHttpClient();
const targetSubPath = mindmapIdRaw ? `mindmaps/${mindmapIdRaw}` : undefined;
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.resource.upload",
payload: {
assetId,
workspaceId,
targetDocumentId: documentId,
targetSubPath,
fileName: file.name || null,
fileSize: file.size,
mimeType: file.type || null,
assetType,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "media-upload tree.resource.upload",
refs: ["file-tree-resource-upload"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
// 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
@@ -78,16 +130,32 @@ export async function POST(request: Request) {
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
targetSubPath: readOptionalStringArg(plan.argsJson, "targetSubPath"),
resourceUploadPlan:
plan.argsJson.resourceUploadPlan && typeof plan.argsJson.resourceUploadPlan === "object"
? plan.argsJson.resourceUploadPlan
: undefined,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: documentId,
asset_type: assetType,
file_name: file.name || null,
file_size: file.size,
mime_type: file.type || null,
id: readOptionalStringArg(plan.argsJson, "assetId") ?? assetId,
workspace_id: readOptionalStringArg(plan.argsJson, "workspaceId") ?? workspaceId,
document_id: readOptionalStringArg(plan.argsJson, "targetDocumentId") ?? documentId,
asset_type: readOptionalStringArg(plan.argsJson, "assetType") ?? assetType,
file_name: readOptionalStringArg(plan.argsJson, "fileName"),
file_size: readNumberArg(plan.argsJson, "fileSize"),
mime_type: readOptionalStringArg(plan.argsJson, "mimeType"),
},
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result: { items: [created] },
});
} catch (error) {
console.warn("[media.upload] Rust bridge artifacts skipped:", error);
}
const asset = created as unknown as MediaAsset;
const safeAsset = {
@@ -6,6 +6,7 @@ const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
const mockRecordBridgeCommandFailureArtifacts = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
@@ -62,12 +63,112 @@ vi.mock("@/lib/documents/bridge", () => ({
},
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: Parameters<typeof mockExecuteRustBridgeMutationTransport>) =>
mockExecuteRustBridgeMutationTransport(...args),
recordRustBridgeCommandArtifacts: (...args: Parameters<typeof mockRecordRustBridgeCommandArtifacts>) =>
mockRecordRustBridgeCommandArtifacts(...args),
readRustTreeDomainEventType: (plan: { argsJson?: Record<string, unknown> }) => {
const eventPlan = plan.argsJson?.domainEventPlan as
| {
family?: string;
eventType?: string;
}
| undefined;
if (eventPlan?.family === "tree" && typeof eventPlan.eventType === "string") {
return eventPlan.eventType;
}
const hint = plan.argsJson?.domainEventHint as
| {
family?: string;
eventType?: string;
}
| undefined;
return hint?.family === "tree" && typeof hint.eventType === "string" ? hint.eventType : null;
},
materializeRustTreeDomainEventPlan: (input: {
plan: { argsJson?: Record<string, unknown> };
streamDelta?: Record<string, unknown> | null;
}) => {
const eventPlan = input.plan.argsJson?.domainEventPlan as
| {
family?: string;
schema?: string;
schemaVersion?: number;
eventType?: string;
}
| undefined;
if (
eventPlan?.family !== "tree" ||
eventPlan.schema !== "mnote.tree.domain_event" ||
eventPlan.schemaVersion !== 1 ||
typeof eventPlan.eventType !== "string"
) {
return null;
}
return {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: eventPlan.eventType,
...(input.streamDelta ? { streamDelta: input.streamDelta } : {}),
};
},
materializeRustTreeStreamDelta: (input: { plan: { argsJson?: Record<string, unknown> }; result: unknown }) => {
const hint = input.plan.argsJson?.streamDeltaHint as
| {
family?: string;
kind?: string;
args?: Record<string, unknown>;
}
| undefined;
if (hint?.family !== "tree" || !hint.kind) return null;
const args = hint.args ?? {};
const result = input.result as Record<string, unknown>;
if (hint.kind === "document_result") {
return result.document ? { op: "upsert_document", document: result.document } : null;
}
if (hint.kind === "upsert_document_patch") {
return {
op: "upsert_document",
document: {
id: args.documentId,
...(args.patch as Record<string, unknown>),
updated_at: result.updated_at ?? null,
},
};
}
if (hint.kind === "move_document") {
return {
op: "move_document",
documentId: args.documentId,
parentId: result.parent_id ?? args.parentId ?? null,
sortOrder: result.sort_order ?? args.sortOrder,
updatedAt: result.updated_at,
};
}
if (hint.kind === "remove_document") {
return { op: "remove_document", documentId: args.documentId };
}
if (hint.kind === "noop") {
return { op: "noop" };
}
if (hint.kind === "copy_result") {
return {
op: "upsert_documents",
upsertDocuments: Array.isArray(result.items)
? result.items.map((item) => item?.document).filter(Boolean)
: [],
};
}
return null;
},
}));
vi.mock("@/lib/documents/bridge-log", () => ({
@@ -92,6 +193,7 @@ describe("/api/tree/commands route", () => {
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockExecuteRustBridgeMutationTransport.mockReset();
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockRecordBridgeCommandArtifacts.mockReset();
mockRecordBridgeCommandFailureArtifacts.mockReset();
mockEnsureDocumentScaffold.mockReset();
@@ -146,7 +248,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "document_result",
args: { documentField: "document" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.created",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
id: "doc_new",
@@ -158,6 +270,18 @@ describe("/api/tree/commands route", () => {
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
document: {
id: "doc_new",
workspace_id: "ws_root",
title: "无标题",
parent_id: null,
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
},
});
const { POST } = await import("./route");
@@ -196,25 +320,26 @@ describe("/api/tree/commands route", () => {
}),
);
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
requestId: "req_tree_1",
traceId: "trace_tree_1",
}),
envelope: expect.objectContaining({
name: "tree.node.create",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "upsert_document",
document: expect.objectContaining({
id: "doc_new",
workspace_id: "ws_root",
title: "无标题",
parent_id: null,
sort_order: 0,
}),
},
plan: expect.objectContaining({
commandName: "tree.node.create",
functionName: "documents:createWithParentReference",
}),
result: expect.objectContaining({
id: "doc_new",
workspace_id: "ws_root",
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
@@ -283,7 +408,21 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
},
},
domainEventHint: {
family: "tree",
eventType: "tree.subtree.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -392,27 +531,18 @@ describe("/api/tree/commands route", () => {
}),
);
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.subtree.move",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "replace_documents",
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
parent_id: null,
},
{
id: "parent_1",
workspace_id: "ws_1",
parent_id: null,
},
],
},
plan: expect.objectContaining({
commandName: "tree.subtree.move",
functionName: "documents:move",
}),
result: expect.objectContaining({
parent_id: "parent_1",
sort_order: 1,
}),
}),
);
@@ -482,7 +612,21 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
},
},
domainEventHint: {
family: "tree",
eventType: "tree.subtree.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -902,7 +1046,20 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "upsert_document_patch",
args: {
documentId: "doc_1",
patch: { title: "新标题" },
},
},
domainEventHint: {
family: "tree",
eventType: "tree.node.renamed",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -949,19 +1106,16 @@ describe("/api/tree/commands route", () => {
}),
);
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.rename",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "upsert_document",
document: expect.objectContaining({
id: "doc_1",
title: "新标题",
}),
},
plan: expect.objectContaining({
commandName: "tree.node.rename",
}),
result: expect.objectContaining({
updated_at: "2026-04-23T00:00:00Z",
}),
}),
);
@@ -1000,7 +1154,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "remove_document",
args: { documentId: "doc_1" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.archived",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -1042,17 +1206,15 @@ describe("/api/tree/commands route", () => {
},
}),
);
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.archive",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "remove_document",
documentId: "doc_1",
},
plan: expect.objectContaining({
commandName: "tree.node.archive",
}),
result: expect.any(Object),
}),
);
});
@@ -1112,7 +1274,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "noop",
args: {},
},
domainEventHint: {
family: "tree",
eventType: "tree.node.embedded",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
revision: 8,
@@ -1170,16 +1342,15 @@ describe("/api/tree/commands route", () => {
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.embed",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "noop",
},
plan: expect.objectContaining({
commandName: "tree.node.embed",
}),
result: expect.any(Object),
}),
);
});
@@ -1222,7 +1393,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "copy_result",
args: { itemsField: "items", documentField: "document" },
},
domainEventHint: {
family: "tree",
eventType: "tree.subtree.copied",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
items: [
@@ -1230,6 +1411,18 @@ describe("/api/tree/commands route", () => {
oldId: "doc_1",
newId: "doc_2",
title: "复制页面",
document: {
id: "doc_2",
workspace_id: "ws_1",
title: "复制页面",
parent_id: "parent_1",
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-24T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
},
},
],
});
@@ -1302,22 +1495,22 @@ describe("/api/tree/commands route", () => {
);
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面");
expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2");
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.subtree.copy",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "replace_documents",
documents: [],
},
plan: expect.objectContaining({
commandName: "tree.subtree.copy",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ newId: "doc_2" })],
}),
}),
);
});
it("restore action 走 tree.node.restore,并附带 replace_documents delta", async () => {
it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async () => ({
@@ -1350,10 +1543,32 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "document_result",
args: { documentField: "document" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.restored",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
document: {
id: "doc_restore_1",
workspace_id: "ws_1",
title: "恢复页面",
parent_id: null,
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
},
updated_at: "2026-04-24T00:00:00Z",
});
mockLoadSidebarDataFromConvex.mockResolvedValue({
@@ -1412,16 +1627,16 @@ describe("/api/tree/commands route", () => {
},
}),
);
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.restore",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "replace_documents",
documents: [],
},
plan: expect.objectContaining({
commandName: "tree.node.restore",
}),
result: expect.objectContaining({
document: expect.objectContaining({ id: "doc_restore_1" }),
}),
}),
);
@@ -1476,7 +1691,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "remove_document",
args: { documentId: "doc_1" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.archived",
},
},
});
mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed"));
+67 -110
View File
@@ -13,7 +13,6 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import {
@@ -24,6 +23,7 @@ import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import {
executeRustBridgeMutationTransport,
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
@@ -57,6 +57,26 @@ type TreeCommandPayload = {
items?: TreeCopyItem[] | null;
};
type TreeDeltaDocument = {
id: string;
workspace_id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean | null;
access_scope: "private" | "shared" | "public";
is_template: boolean;
created_at: string;
updated_at: string | null;
};
type TreeMutationResult<TResult> = {
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
result: TResult;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -76,37 +96,23 @@ 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;
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
result: unknown;
}) {
try {
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
context: args.context,
envelope: args.envelope,
client: args.client,
commandPayload: args.commandPayload,
plan: args.plan,
result: args.result,
});
} catch (error) {
console.warn("[tree.commands] bridge success artifacts skipped:", error);
console.warn("[tree.commands] Rust bridge success artifacts skipped:", error);
}
}
@@ -136,24 +142,6 @@ async function loadTreeCommandSidebarSnapshot(args: {
}
}
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 {
@@ -203,6 +191,7 @@ async function resolveTreeMutationResult<TResult>(args: {
return {
context,
envelope,
plan,
result,
};
} catch (error) {
@@ -275,8 +264,9 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
const title = normalizeTitle(payload.title);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
id: string;
document?: TreeDeltaDocument | null;
title: string | null;
parent_id: string | null;
sort_order: number | null;
@@ -300,27 +290,15 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
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,
},
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -356,7 +334,7 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
workspaceId,
});
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
parent_id?: string | null;
sort_order?: number | null;
@@ -375,19 +353,13 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -426,8 +398,9 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const title = assertTitle(payload.title ?? null);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
document?: TreeDeltaDocument | null;
updated_at?: string | null;
}>({
request,
@@ -441,18 +414,13 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: documentId,
title,
updated_at: result?.updated_at ?? null,
},
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -478,8 +446,9 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
document?: TreeDeltaDocument | null;
updated_at?: string | null;
}>({
request,
@@ -492,14 +461,13 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -516,7 +484,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
}
async function handleRestore(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
@@ -524,8 +492,9 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
document?: TreeDeltaDocument | null;
updated_at?: string | null;
}>({
request,
@@ -538,19 +507,13 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -575,7 +538,7 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
purged?: boolean;
purged_at?: string | null;
@@ -589,14 +552,13 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -659,7 +621,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
revision?: number | null;
conflict_detection_key?: string | null;
}>({
@@ -688,13 +650,13 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
pageId: targetId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "noop",
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -712,7 +674,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
}
async function handleCopy(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const { client } = await getAuthedConvexClient();
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
@@ -747,11 +709,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
document?: TreeDeltaDocument | null;
}>;
}>({
request,
@@ -765,6 +728,7 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
pageId: targetParentId,
client,
});
const { context, envelope, result } = mutation;
await Promise.all(
(result.items ?? []).map(async (item) => {
@@ -772,19 +736,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/delete-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.delete.preflight 返回规范化 delete plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_delete_1",
traceId: "trace_filetree_delete_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.delete.preflight",
commandId: "cmd_filetree_delete_1",
functionName: "tree:fileTreeDeletePreflight",
workspaceId: "ws_1",
requestId: "req_filetree_delete_1",
traceId: "trace_filetree_delete_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreeDeletePlan: {
rowIds: ["doc:doc_1", "asset:asset_1"],
docIds: ["doc_1"],
assetIds: ["asset_1"],
assetDocumentIds: ["doc_other"],
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/delete-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
rows: [],
documentParents: [],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_delete_1",
traceId: "trace_filetree_delete_1",
plan: {
rowIds: ["doc:doc_1", "asset:asset_1"],
docIds: ["doc_1"],
assetIds: ["asset_1"],
assetDocumentIds: ["doc_other"],
},
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.delete.preflight",
payload: expect.objectContaining({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
}),
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreeDeletePreflightPayload = {
workspaceId?: string | null;
rowIds?: string[];
rows?: unknown[];
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
}
function readFileTreeDeletePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
const value = plan.argsJson.fileTreeDeletePlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreeDeletePlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreeDeletePreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
rowIds: normalizeStringArray(payload.rowIds),
rows: Array.isArray(payload.rows) ? payload.rows : [],
documentParents: Array.isArray(payload.documentParents)
? payload.documentParents.map((item) => ({
documentId: trimOrNull(item?.documentId),
parentId: trimOrNull(item?.parentId),
}))
: [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.delete.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
},
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreeDeletePlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/drop-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.drop.preflight 返回规范化 drop plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_drop_1",
traceId: "trace_filetree_drop_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.drop.preflight",
commandId: "cmd_filetree_drop_1",
functionName: "tree:fileTreeDropPreflight",
workspaceId: "ws_1",
requestId: "req_filetree_drop_1",
traceId: "trace_filetree_drop_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreeDropPlan: {
copy: false,
targetDocumentId: "doc_target",
targetMindmapId: null,
targetSubPath: null,
rowIds: ["doc:doc_1"],
docIds: ["doc_1"],
topLevelDocIds: ["doc_1"],
copyableAssetIds: [],
sourceAssetDocumentIds: [],
documentTransferPlan: {
action: "move",
targetParentId: "doc_target",
documentIds: ["doc_1"],
topLevelDocumentIds: ["doc_1"],
copyItems: [{ documentId: "doc_1", recursive: true }],
},
resourceTransferPlan: null,
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/drop-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
copy: false,
targetDocumentId: "doc_target",
targetRowId: null,
focusedRowId: null,
activeDocumentId: null,
rowIds: ["doc:doc_1"],
rows: [],
documentParents: [],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_drop_1",
traceId: "trace_filetree_drop_1",
plan: {
copy: false,
targetDocumentId: "doc_target",
targetMindmapId: null,
targetSubPath: null,
rowIds: ["doc:doc_1"],
docIds: ["doc_1"],
topLevelDocIds: ["doc_1"],
copyableAssetIds: [],
sourceAssetDocumentIds: [],
documentTransferPlan: {
action: "move",
targetParentId: "doc_target",
documentIds: ["doc_1"],
topLevelDocumentIds: ["doc_1"],
copyItems: [{ documentId: "doc_1", recursive: true }],
},
resourceTransferPlan: null,
},
});
expect(mockBuildDocumentBridgeContext).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
validateOnly: true,
dryRun: true,
}),
);
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.drop.preflight",
payload: expect.objectContaining({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
rowIds: ["doc:doc_1"],
}),
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreeDropPreflightPayload = {
workspaceId?: string | null;
copy?: boolean;
targetDocumentId?: string | null;
targetRowId?: string | null;
focusedRowId?: string | null;
activeDocumentId?: string | null;
rowIds?: string[];
rows?: unknown[];
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
}
function readFileTreeDropPlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
const value = plan.argsJson.fileTreeDropPlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreeDropPlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreeDropPreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
copy: Boolean(payload.copy),
targetDocumentId: trimOrNull(payload.targetDocumentId),
targetRowId: trimOrNull(payload.targetRowId),
focusedRowId: trimOrNull(payload.focusedRowId),
activeDocumentId: trimOrNull(payload.activeDocumentId),
rowIds: normalizeStringArray(payload.rowIds),
rows: Array.isArray(payload.rows) ? payload.rows : [],
documentParents: Array.isArray(payload.documentParents)
? payload.documentParents.map((item) => ({
documentId: trimOrNull(item?.documentId),
parentId: trimOrNull(item?.parentId),
}))
: [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.drop.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
pageId: normalizedPayload.targetDocumentId ?? undefined,
},
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreeDropPlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/paste-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.paste.preflight 返回规范化 paste plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_paste_1",
traceId: "trace_filetree_paste_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.paste.preflight",
commandId: "cmd_filetree_paste_1",
functionName: "tree:fileTreePastePreflight",
workspaceId: "ws_1",
requestId: "req_filetree_paste_1",
traceId: "trace_filetree_paste_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreePastePlan: {
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
docItems: [{ documentId: "doc_1", recursive: false }],
copyableAssetIds: ["asset_1"],
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/paste-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_1", "asset:asset_1"],
rows: [],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_paste_1",
traceId: "trace_filetree_paste_1",
plan: {
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
docItems: [{ documentId: "doc_1", recursive: false }],
copyableAssetIds: ["asset_1"],
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.paste.preflight",
payload: expect.objectContaining({
workspaceId: "ws_1",
focusedRowId: "asset-folder:mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
}),
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreePastePreflightPayload = {
workspaceId?: string | null;
targetDocumentId?: string | null;
focusedRowId?: string | null;
activeDocumentId?: string | null;
rowIds?: string[];
rows?: unknown[];
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
}
function readFileTreePastePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
const value = plan.argsJson.fileTreePastePlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreePastePlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreePastePreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
targetDocumentId: trimOrNull(payload.targetDocumentId),
focusedRowId: trimOrNull(payload.focusedRowId),
activeDocumentId: trimOrNull(payload.activeDocumentId),
rowIds: normalizeStringArray(payload.rowIds),
rows: Array.isArray(payload.rows) ? payload.rows : [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.paste.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
pageId: normalizedPayload.targetDocumentId ?? undefined,
},
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreePastePlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/upload-target-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.upload-target.preflight 返回规范化 upload target plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_upload_target_1",
traceId: "trace_filetree_upload_target_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.upload-target.preflight",
commandId: "cmd_filetree_upload_target_1",
functionName: "tree:fileTreeUploadTargetPreflight",
workspaceId: "ws_1",
requestId: "req_filetree_upload_target_1",
traceId: "trace_filetree_upload_target_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreeUploadTargetPlan: {
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/upload-target-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: null,
activeDocumentId: "doc_active",
rows: [],
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_upload_target_1",
traceId: "trace_filetree_upload_target_1",
plan: {
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
},
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.upload-target.preflight",
payload: expect.objectContaining({
workspaceId: "ws_fallback",
targetRowId: "asset:asset_child_1",
activeDocumentId: "doc_active",
}),
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreeUploadTargetPreflightPayload = {
workspaceId?: string | null;
targetDocumentId?: string | null;
targetRowId?: string | null;
focusedRowId?: string | null;
activeDocumentId?: string | null;
rows?: unknown[];
documentWorkspaces?: Array<{ documentId?: string | null; workspaceId?: string | null }>;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readFileTreeUploadTargetPlan(
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>,
) {
const value = plan.argsJson.fileTreeUploadTargetPlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreeUploadTargetPlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreeUploadTargetPreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
targetDocumentId: trimOrNull(payload.targetDocumentId),
targetRowId: trimOrNull(payload.targetRowId),
focusedRowId: trimOrNull(payload.focusedRowId),
activeDocumentId: trimOrNull(payload.activeDocumentId),
rows: Array.isArray(payload.rows) ? payload.rows : [],
documentWorkspaces: Array.isArray(payload.documentWorkspaces)
? payload.documentWorkspaces.map((item) => ({
documentId: trimOrNull(item?.documentId),
workspaceId: trimOrNull(item?.workspaceId),
}))
: [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.upload-target.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
pageId: normalizedPayload.targetDocumentId ?? undefined,
},
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreeUploadTargetPlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveKernelFileTreeProjection = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: (...args: unknown[]) =>
mockExecuteRustBridgeQueryTransport(...args),
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...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),
};
});
describe("/api/tree/projections/file route", () => {
beforeEach(() => {
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
auth: {
userId: "user_1",
},
client: {
query: vi.fn(),
mutation: vi.fn(),
},
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
});
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
functionName: "sidebar:datasetList",
argsJson: {
workspaceId: "ws_1",
},
});
mockExecuteRustBridgeQueryTransport.mockReset().mockResolvedValue({
active_workspace_id: "ws_1",
documents: [],
media_assets: [],
mindmap_assets: [],
table_assets: [],
mindmap_asset_children: {},
});
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
projectionId: "kernel_projection:file_tree:page_root",
projection: "file_tree",
rootNodeId: "page_root",
items: [
{
rowId: "doc:page_root",
nodeId: "page_root",
projectionKind: "file_tree",
rowKind: "document",
},
{
rowId: "asset:table_1",
nodeId: "asset:table_1",
projectionKind: "file_tree",
rowKind: "asset",
},
],
edges: [],
});
mockDocumentBridgeErrorResponse.mockClear();
});
it("通过 3000 同源 route 返回 Rust file_tree 搜索 projection", 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/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
{ method: "GET" },
),
);
expect(response.status).toBe(200);
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
depth: 3,
query: "预算",
maxResults: 12,
}),
);
const body = await response.json();
expect(body).toMatchObject({
ok: true,
result: {
projection: "file_tree",
rootNodeId: "page_root",
},
});
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
"doc:page_root",
"asset:table_1",
]);
});
it("Convex 未启用时返回 501", async () => {
mockIsConvexEnabled.mockReturnValue(false);
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1"),
);
expect(response.status).toBe(501);
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
});
});
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
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;
}
export async function GET(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const url = new URL(request.url);
const workspaceId = url.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
try {
const { auth, client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const projection = await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
dataset,
rootNodeId: url.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(url, "depth"),
query: url.searchParams.get("query")?.trim() || null,
maxResults: readNumberParam(url, "maxResults"),
});
return NextResponse.json({
ok: true,
requestId: context.requestId,
traceId: context.traceId,
result: projection,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -20,7 +20,23 @@ describe("/api/tree/shell route", () => {
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
it("未显式 debug 时不应再代理 3104 tree shell", async () => {
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
),
);
expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
expect(response.status).toBe(404);
expect(await response.json()).toEqual({
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
});
});
it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
@@ -43,7 +59,7 @@ describe("/api/tree/shell route", () => {
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
{
method: "GET",
headers: {
@@ -54,7 +70,7 @@ describe("/api/tree/shell route", () => {
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
expect.objectContaining({
method: "GET",
headers: expect.any(Headers),
@@ -24,6 +24,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const debugEnabled =
requestUrl.searchParams.get("debug") === "1" ||
requestUrl.searchParams.get("internal") === "1" ||
process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1";
if (!debugEnabled) {
return NextResponse.json(
{
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
},
{ status: 404 },
);
}
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;