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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user