feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockMaterializeRustTreeStreamDelta = vi.fn();
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
const mockReadRustTreeDomainEventType = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
listByIds: "mediaAssets:listByIds",
patchById: "mediaAssets:patchById",
},
documents: {
getMeta: "documents:getMeta",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) =>
mockExecuteRustBridgeMutationTransport(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
materializeRustTreeStreamDelta: (...args: unknown[]) =>
mockMaterializeRustTreeStreamDelta(...args),
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
mockMaterializeRustTreeDomainEventPlan(...args),
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
}));
vi.mock("@/lib/url/proxyForBrowser", () => ({
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
}));
describe("/api/media/batch route", () => {
beforeEach(() => {
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({
userId: "user_1",
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_asset_1",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.move",
commandId: "cmd_asset_1",
functionName: "mediaAssets:batchMove",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.moved",
},
domainEventHint: {
family: "tree",
eventType: "tree.resource.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({
items: [
{
id: "asset_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/file.pdf",
thumbnail_url: "/file.pdf",
file_name: "file.pdf",
file_size: 1024,
mime_type: "application/pdf",
ocr_text: null,
ocr_status: null,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
},
],
});
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
op: "upsert_assets",
upsertAssets: [{ id: "asset_1" }],
});
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.moved",
streamDelta: {
op: "upsert_assets",
upsertAssets: [{ id: "asset_1" }],
},
});
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.moved");
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("copy/move 应进入 Rust resource command plan 并记录资源 delta artifact", async () => {
const client = {
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:listByIds") {
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
return [{ id: "asset_1", workspace_id: "ws_1" }];
}
if (name === "documents:getMeta") {
expect(args).toEqual({ id: "doc_target" });
return { id: "doc_target", workspace_id: "ws_1" };
}
return null;
}),
mutation: vi.fn(),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/media/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
items: [
{
id: "asset_1",
document_id: "doc_target",
},
],
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.move",
payload: {
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
target: {
workspaceId: "ws_1",
pageId: "doc_target",
},
}),
);
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith(
expect.objectContaining({
client,
plan: expect.objectContaining({
functionName: "mediaAssets:batchMove",
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.resource.move",
}),
plan: expect.objectContaining({
commandName: "tree.resource.move",
functionName: "mediaAssets:batchMove",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ id: "asset_1" })],
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
+65 -94
View File
@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
@@ -19,41 +25,6 @@ interface BatchPayload {
targetSubPath?: string;
newName?: string;
}
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
const parseStoragePath = (fileUrl: string) => {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const path = segments.slice(objectIdx + 3).join("/");
return { bucket, path };
}
return null;
} catch {
return null;
}
};
function resolveAssetLocation(asset: any): { bucket: string; path: string } | null {
if (asset?.storage_path) {
return { bucket: asset.bucket || BUCKET, path: asset.storage_path };
}
if (asset?.file_url) {
return parseStoragePath(asset.file_url);
}
return null;
}
function sanitizeSubPath(input: string | undefined): string {
const raw = typeof input === "string" ? input : "";
@@ -67,6 +38,14 @@ function sanitizeSubPath(input: string | undefined): string {
return cleaned.join("/");
}
function sanitizeTransferredAssetForBrowser(request: Request, asset: any) {
return {
...asset,
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
};
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -149,65 +128,57 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
const existing = (await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId: payload.targetDocumentId,
limit: 500,
})) as any[];
const existingNames = new Set<string>(
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
);
const results: any[] = [];
for (const asset of assets) {
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
if (!storageId) continue;
if (payload.action === "copy") {
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
asset: {
id: newId,
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
asset_type: String(asset.asset_type ?? "file"),
file_name: fileName,
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
mime_type: (asset.mime_type ?? null) as any,
},
});
results.push(created);
} else {
await client.mutation(api.mediaAssets.patchById, {
userId: auth.userId,
id: String(asset.id),
patch: {
workspace_id: String(targetDoc.workspace_id),
document_id: String(payload.targetDocumentId),
file_name: fileName,
},
});
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
}
const workspaceId = String(targetDoc.workspace_id ?? "").trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const safeItems = (results ?? []).map((a: any) => ({
...a,
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
}));
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const commandName =
payload.action === "copy" ? "tree.resource.copy" : "tree.resource.move";
const envelope = buildDocumentCommandEnvelope({
name: commandName,
payload: {
assetIds: payload.assetIds,
targetDocumentId: payload.targetDocumentId,
targetSubPath: sanitizeSubPath(payload.targetSubPath),
},
context,
target: {
workspaceId,
pageId: payload.targetDocumentId,
},
reason: `media-batch ${commandName}`,
refs: ["file-tree-resource-command"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
const result = await executeRustBridgeMutationTransport<{ items?: any[] }>({
client: client as never,
plan,
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result,
});
} catch (error) {
console.warn("[media.batch] Rust bridge artifacts skipped:", error);
}
return NextResponse.json({ items: safeItems });
const safeItems = (result.items ?? []).map((item: any) =>
sanitizeTransferredAssetForBrowser(request, item),
);
return NextResponse.json({ items: safeItems });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockMaterializeRustTreeStreamDelta = vi.fn();
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
const mockReadRustTreeDomainEventType = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: () => mockRequireAuthContext(),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
mediaAssets: {
generateUploadUrl: "mediaAssets:generateUploadUrl",
createWithStorage: "mediaAssets:createWithStorage",
},
},
}));
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
mockRecordRustBridgeCommandArtifacts(...args),
materializeRustTreeStreamDelta: (...args: unknown[]) =>
mockMaterializeRustTreeStreamDelta(...args),
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
mockMaterializeRustTreeDomainEventPlan(...args),
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
}));
vi.mock("@/lib/url/proxyForBrowser", () => ({
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
}));
describe("/api/media/upload route", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockRequireAuthContext.mockReset().mockResolvedValue({
userId: "user_1",
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
actor: { actorType: "user", actorId: "user_1", sessionId: null },
source: { channel: "next-route", client: "vitest" },
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
...(input as Record<string, unknown>),
commandId: "cmd_upload_asset",
idempotencyKey: null,
}));
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
kind: "command",
commandName: "tree.resource.upload",
commandId: "cmd_upload_asset",
functionName: "mediaAssets:createWithStorage",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.uploaded",
},
assetId: "asset_upload_1",
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: "demo.pdf",
fileSize: 7,
mimeType: "application/pdf",
assetType: "file",
resourceUploadPlan: {
action: "upload",
assetId: "asset_upload_1",
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: "demo.pdf",
fileSize: 7,
mimeType: "application/pdf",
assetType: "file",
},
},
});
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
op: "upsert_assets",
upsertAssets: [{ id: "asset_upload_1" }],
});
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.resource.uploaded",
streamDelta: {
op: "upsert_assets",
upsertAssets: [{ id: "asset_upload_1" }],
},
});
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.uploaded");
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
});
it("上传应通过 tree.resource.upload plan 写入资源元数据并记录资源 delta artifact", async () => {
const createdAsset = {
id: "asset_upload_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/files/demo.pdf",
thumbnail_url: "/files/demo.pdf",
file_name: "demo.pdf",
file_size: 7,
mime_type: "application/pdf",
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const client = {
mutation: vi.fn(async (name: string, args: Record<string, unknown>) => {
if (name === "mediaAssets:generateUploadUrl") {
expect(args).toEqual({ userId: "user_1" });
return "https://convex.test/upload";
}
if (name === "mediaAssets:createWithStorage") {
expect(args).toMatchObject({
userId: "user_1",
storageId: "storage_1",
targetSubPath: "mindmaps/mind_1",
resourceUploadPlan: {
action: "upload",
assetId: "asset_upload_1",
targetDocumentId: "doc_target",
},
asset: {
id: "asset_upload_1",
workspace_id: "ws_1",
document_id: "doc_target",
file_name: "demo.pdf",
},
});
return createdAsset;
}
return null;
}),
};
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ storageId: "storage_1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const { POST } = await import("./route");
const formData = new FormData();
formData.append("file", new Blob(["content"], { type: "application/pdf" }), "demo.pdf");
formData.set("workspaceId", "ws_1");
formData.set("documentId", "doc_target");
formData.set("mindmapId", "mind_1");
const response = await POST(
new Request("http://localhost/api/media/upload", {
method: "POST",
body: formData,
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
asset: {
id: "asset_upload_1",
document_id: "doc_target",
},
mindmapUrl: "asset:asset_upload_1",
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.resource.upload",
payload: expect.objectContaining({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
fileName: expect.any(String),
}),
}),
);
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.resource.upload",
}),
plan: expect.objectContaining({
commandName: "tree.resource.upload",
functionName: "mediaAssets:createWithStorage",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ id: "asset_upload_1" })],
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
@@ -1,18 +1,21 @@
import { NextResponse } from "next/server";
import type { MediaAsset } from "@/types/media";
import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export const dynamic = "force-dynamic";
const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
@@ -20,6 +23,26 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
return "file";
};
function isUploadFile(value: FormDataEntryValue | null): value is File {
return Boolean(
value &&
typeof value === "object" &&
(typeof File === "undefined" || value instanceof File || "arrayBuffer" in value) &&
typeof (value as File).arrayBuffer === "function" &&
typeof (value as File).name === "string",
);
}
function readOptionalStringArg(args: Record<string, unknown>, key: string): string | null {
const value = args[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readNumberArg(args: Record<string, unknown>, key: string): number | null {
const value = args[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -38,7 +61,7 @@ export async function POST(request: Request) {
const documentId = String(formData.get("documentId") ?? "");
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
if (!(file instanceof File) || !workspaceId || !documentId) {
if (!isUploadFile(file) || !workspaceId || !documentId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
}
@@ -51,6 +74,35 @@ export async function POST(request: Request) {
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || "");
const client = await getConvexAuthedHttpClient();
const targetSubPath = mindmapIdRaw ? `mindmaps/${mindmapIdRaw}` : undefined;
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.resource.upload",
payload: {
assetId,
workspaceId,
targetDocumentId: documentId,
targetSubPath,
fileName: file.name || null,
fileSize: file.size,
mimeType: file.type || null,
assetType,
},
context,
target: {
workspaceId,
pageId: documentId,
},
reason: "media-upload tree.resource.upload",
refs: ["file-tree-resource-upload"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
// 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
@@ -78,16 +130,32 @@ export async function POST(request: Request) {
const created = await client.mutation(api.mediaAssets.createWithStorage, {
userId: auth.userId,
storageId: storageId as any,
targetSubPath: readOptionalStringArg(plan.argsJson, "targetSubPath"),
resourceUploadPlan:
plan.argsJson.resourceUploadPlan && typeof plan.argsJson.resourceUploadPlan === "object"
? plan.argsJson.resourceUploadPlan
: undefined,
asset: {
id: assetId,
workspace_id: workspaceId,
document_id: documentId,
asset_type: assetType,
file_name: file.name || null,
file_size: file.size,
mime_type: file.type || null,
id: readOptionalStringArg(plan.argsJson, "assetId") ?? assetId,
workspace_id: readOptionalStringArg(plan.argsJson, "workspaceId") ?? workspaceId,
document_id: readOptionalStringArg(plan.argsJson, "targetDocumentId") ?? documentId,
asset_type: readOptionalStringArg(plan.argsJson, "assetType") ?? assetType,
file_name: readOptionalStringArg(plan.argsJson, "fileName"),
file_size: readNumberArg(plan.argsJson, "fileSize"),
mime_type: readOptionalStringArg(plan.argsJson, "mimeType"),
},
});
try {
await recordRustBridgeCommandArtifacts({
context,
envelope,
client: client as never,
plan,
result: { items: [created] },
});
} catch (error) {
console.warn("[media.upload] Rust bridge artifacts skipped:", error);
}
const asset = created as unknown as MediaAsset;
const safeAsset = {
@@ -6,6 +6,7 @@ const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockExecuteRustBridgeMutationTransport = vi.fn();
const mockRecordRustBridgeCommandArtifacts = vi.fn();
const mockRecordBridgeCommandArtifacts = vi.fn();
const mockRecordBridgeCommandFailureArtifacts = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
@@ -62,12 +63,112 @@ vi.mock("@/lib/documents/bridge", () => ({
},
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
executeRustBridgeMutationTransport: (...args: Parameters<typeof mockExecuteRustBridgeMutationTransport>) =>
mockExecuteRustBridgeMutationTransport(...args),
recordRustBridgeCommandArtifacts: (...args: Parameters<typeof mockRecordRustBridgeCommandArtifacts>) =>
mockRecordRustBridgeCommandArtifacts(...args),
readRustTreeDomainEventType: (plan: { argsJson?: Record<string, unknown> }) => {
const eventPlan = plan.argsJson?.domainEventPlan as
| {
family?: string;
eventType?: string;
}
| undefined;
if (eventPlan?.family === "tree" && typeof eventPlan.eventType === "string") {
return eventPlan.eventType;
}
const hint = plan.argsJson?.domainEventHint as
| {
family?: string;
eventType?: string;
}
| undefined;
return hint?.family === "tree" && typeof hint.eventType === "string" ? hint.eventType : null;
},
materializeRustTreeDomainEventPlan: (input: {
plan: { argsJson?: Record<string, unknown> };
streamDelta?: Record<string, unknown> | null;
}) => {
const eventPlan = input.plan.argsJson?.domainEventPlan as
| {
family?: string;
schema?: string;
schemaVersion?: number;
eventType?: string;
}
| undefined;
if (
eventPlan?.family !== "tree" ||
eventPlan.schema !== "mnote.tree.domain_event" ||
eventPlan.schemaVersion !== 1 ||
typeof eventPlan.eventType !== "string"
) {
return null;
}
return {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: eventPlan.eventType,
...(input.streamDelta ? { streamDelta: input.streamDelta } : {}),
};
},
materializeRustTreeStreamDelta: (input: { plan: { argsJson?: Record<string, unknown> }; result: unknown }) => {
const hint = input.plan.argsJson?.streamDeltaHint as
| {
family?: string;
kind?: string;
args?: Record<string, unknown>;
}
| undefined;
if (hint?.family !== "tree" || !hint.kind) return null;
const args = hint.args ?? {};
const result = input.result as Record<string, unknown>;
if (hint.kind === "document_result") {
return result.document ? { op: "upsert_document", document: result.document } : null;
}
if (hint.kind === "upsert_document_patch") {
return {
op: "upsert_document",
document: {
id: args.documentId,
...(args.patch as Record<string, unknown>),
updated_at: result.updated_at ?? null,
},
};
}
if (hint.kind === "move_document") {
return {
op: "move_document",
documentId: args.documentId,
parentId: result.parent_id ?? args.parentId ?? null,
sortOrder: result.sort_order ?? args.sortOrder,
updatedAt: result.updated_at,
};
}
if (hint.kind === "remove_document") {
return { op: "remove_document", documentId: args.documentId };
}
if (hint.kind === "noop") {
return { op: "noop" };
}
if (hint.kind === "copy_result") {
return {
op: "upsert_documents",
upsertDocuments: Array.isArray(result.items)
? result.items.map((item) => item?.document).filter(Boolean)
: [],
};
}
return null;
},
}));
vi.mock("@/lib/documents/bridge-log", () => ({
@@ -92,6 +193,7 @@ describe("/api/tree/commands route", () => {
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockExecuteRustBridgeMutationTransport.mockReset();
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
mockRecordBridgeCommandArtifacts.mockReset();
mockRecordBridgeCommandFailureArtifacts.mockReset();
mockEnsureDocumentScaffold.mockReset();
@@ -146,7 +248,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "document_result",
args: { documentField: "document" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.created",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
id: "doc_new",
@@ -158,6 +270,18 @@ describe("/api/tree/commands route", () => {
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
document: {
id: "doc_new",
workspace_id: "ws_root",
title: "无标题",
parent_id: null,
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
},
});
const { POST } = await import("./route");
@@ -196,25 +320,26 @@ describe("/api/tree/commands route", () => {
}),
);
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
requestId: "req_tree_1",
traceId: "trace_tree_1",
}),
envelope: expect.objectContaining({
name: "tree.node.create",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "upsert_document",
document: expect.objectContaining({
id: "doc_new",
workspace_id: "ws_root",
title: "无标题",
parent_id: null,
sort_order: 0,
}),
},
plan: expect.objectContaining({
commandName: "tree.node.create",
functionName: "documents:createWithParentReference",
}),
result: expect.objectContaining({
id: "doc_new",
workspace_id: "ws_root",
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
@@ -283,7 +408,21 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
},
},
domainEventHint: {
family: "tree",
eventType: "tree.subtree.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -392,27 +531,18 @@ describe("/api/tree/commands route", () => {
}),
);
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.subtree.move",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "replace_documents",
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
parent_id: null,
},
{
id: "parent_1",
workspace_id: "ws_1",
parent_id: null,
},
],
},
plan: expect.objectContaining({
commandName: "tree.subtree.move",
functionName: "documents:move",
}),
result: expect.objectContaining({
parent_id: "parent_1",
sort_order: 1,
}),
}),
);
@@ -482,7 +612,21 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
},
},
domainEventHint: {
family: "tree",
eventType: "tree.subtree.moved",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -902,7 +1046,20 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "upsert_document_patch",
args: {
documentId: "doc_1",
patch: { title: "新标题" },
},
},
domainEventHint: {
family: "tree",
eventType: "tree.node.renamed",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -949,19 +1106,16 @@ describe("/api/tree/commands route", () => {
}),
);
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.rename",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "upsert_document",
document: expect.objectContaining({
id: "doc_1",
title: "新标题",
}),
},
plan: expect.objectContaining({
commandName: "tree.node.rename",
}),
result: expect.objectContaining({
updated_at: "2026-04-23T00:00:00Z",
}),
}),
);
@@ -1000,7 +1154,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "remove_document",
args: { documentId: "doc_1" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.archived",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
@@ -1042,17 +1206,15 @@ describe("/api/tree/commands route", () => {
},
}),
);
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.archive",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "remove_document",
documentId: "doc_1",
},
plan: expect.objectContaining({
commandName: "tree.node.archive",
}),
result: expect.any(Object),
}),
);
});
@@ -1112,7 +1274,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "noop",
args: {},
},
domainEventHint: {
family: "tree",
eventType: "tree.node.embedded",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
revision: 8,
@@ -1170,16 +1342,15 @@ describe("/api/tree/commands route", () => {
}),
}),
);
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.embed",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "noop",
},
plan: expect.objectContaining({
commandName: "tree.node.embed",
}),
result: expect.any(Object),
}),
);
});
@@ -1222,7 +1393,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "copy_result",
args: { itemsField: "items", documentField: "document" },
},
domainEventHint: {
family: "tree",
eventType: "tree.subtree.copied",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
items: [
@@ -1230,6 +1411,18 @@ describe("/api/tree/commands route", () => {
oldId: "doc_1",
newId: "doc_2",
title: "复制页面",
document: {
id: "doc_2",
workspace_id: "ws_1",
title: "复制页面",
parent_id: "parent_1",
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-24T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
},
},
],
});
@@ -1302,22 +1495,22 @@ describe("/api/tree/commands route", () => {
);
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面");
expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2");
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.subtree.copy",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "replace_documents",
documents: [],
},
plan: expect.objectContaining({
commandName: "tree.subtree.copy",
}),
result: expect.objectContaining({
items: [expect.objectContaining({ newId: "doc_2" })],
}),
}),
);
});
it("restore action 走 tree.node.restore,并附带 replace_documents delta", async () => {
it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => {
const client = {
mutation: vi.fn(),
query: vi.fn(async () => ({
@@ -1350,10 +1543,32 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "document_result",
args: { documentField: "document" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.restored",
},
},
});
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
ok: true,
document: {
id: "doc_restore_1",
workspace_id: "ws_1",
title: "恢复页面",
parent_id: null,
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-24T00:00:00Z",
},
updated_at: "2026-04-24T00:00:00Z",
});
mockLoadSidebarDataFromConvex.mockResolvedValue({
@@ -1412,16 +1627,16 @@ describe("/api/tree/commands route", () => {
},
}),
);
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
envelope: expect.objectContaining({
name: "tree.node.restore",
}),
commandPayload: expect.objectContaining({
streamDelta: {
op: "replace_documents",
documents: [],
},
plan: expect.objectContaining({
commandName: "tree.node.restore",
}),
result: expect.objectContaining({
document: expect.objectContaining({ id: "doc_restore_1" }),
}),
}),
);
@@ -1476,7 +1691,17 @@ describe("/api/tree/commands route", () => {
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {},
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "remove_document",
args: { documentId: "doc_1" },
},
domainEventHint: {
family: "tree",
eventType: "tree.node.archived",
},
},
});
mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed"));
+67 -110
View File
@@ -13,7 +13,6 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import {
@@ -24,6 +23,7 @@ import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
import {
executeRustBridgeMutationTransport,
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
@@ -57,6 +57,26 @@ type TreeCommandPayload = {
items?: TreeCopyItem[] | null;
};
type TreeDeltaDocument = {
id: string;
workspace_id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean | null;
access_scope: "private" | "shared" | "public";
is_template: boolean;
created_at: string;
updated_at: string | null;
};
type TreeMutationResult<TResult> = {
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
result: TResult;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -76,37 +96,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
if (!streamDelta) {
return commandPayload;
}
if (isRecord(commandPayload)) {
return {
...commandPayload,
streamDelta,
};
}
return {
payload: commandPayload,
streamDelta,
};
}
async function recordTreeCommandSuccess(args: {
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
commandPayload?: unknown;
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
result: unknown;
}) {
try {
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
context: args.context,
envelope: args.envelope,
client: args.client,
commandPayload: args.commandPayload,
plan: args.plan,
result: args.result,
});
} catch (error) {
console.warn("[tree.commands] bridge success artifacts skipped:", error);
console.warn("[tree.commands] Rust bridge success artifacts skipped:", error);
}
}
@@ -136,24 +142,6 @@ async function loadTreeCommandSidebarSnapshot(args: {
}
}
function buildTreeCommandSnapshotDelta(
sidebarSnapshot: unknown,
): Record<string, unknown> | null {
if (!isRecord(sidebarSnapshot)) {
return null;
}
if (Array.isArray(sidebarSnapshot.documents)) {
return {
op: "replace_documents",
documents: sidebarSnapshot.documents,
};
}
return {
op: "replace_sidebar",
sidebar: sidebarSnapshot,
};
}
function buildTreeMovePreflightDataFromSidebarSnapshot(
sidebarSnapshot: unknown,
): Record<string, unknown> | null {
@@ -203,6 +191,7 @@ async function resolveTreeMutationResult<TResult>(args: {
return {
context,
envelope,
plan,
result,
};
} catch (error) {
@@ -275,8 +264,9 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
const title = normalizeTitle(payload.title);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
id: string;
document?: TreeDeltaDocument | null;
title: string | null;
parent_id: string | null;
sort_order: number | null;
@@ -300,27 +290,15 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await ensureDocumentScaffold(result.id, result.title ?? title);
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: result.id,
workspace_id: result.workspace_id,
title: result.title ?? title,
parent_id: result.parent_id ?? parentId,
sort_order: result.sort_order ?? 0,
access_scope: result.access_scope,
is_starred: false,
is_template: result.is_template,
created_at: result.created_at,
updated_at: result.updated_at,
},
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -356,7 +334,7 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
workspaceId,
});
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
parent_id?: string | null;
sort_order?: number | null;
@@ -375,19 +353,13 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -426,8 +398,9 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const title = assertTitle(payload.title ?? null);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
document?: TreeDeltaDocument | null;
updated_at?: string | null;
}>({
request,
@@ -441,18 +414,13 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "upsert_document",
document: {
id: documentId,
title,
updated_at: result?.updated_at ?? null,
},
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -478,8 +446,9 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
document?: TreeDeltaDocument | null;
updated_at?: string | null;
}>({
request,
@@ -492,14 +461,13 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -516,7 +484,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
}
async function handleRestore(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const { client } = await getAuthedConvexClient();
const documentId = assertDocumentId(payload.documentId ?? null);
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
@@ -524,8 +492,9 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
document?: TreeDeltaDocument | null;
updated_at?: string | null;
}>({
request,
@@ -538,19 +507,13 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -575,7 +538,7 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
}
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
ok?: boolean;
purged?: boolean;
purged_at?: string | null;
@@ -589,14 +552,13 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
pageId: documentId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "remove_document",
documentId,
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -659,7 +621,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
trimOrNull(sourceDoc.workspace_id) ??
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
revision?: number | null;
conflict_detection_key?: string | null;
}>({
@@ -688,13 +650,13 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
pageId: targetId,
client,
});
const { context, envelope, result } = mutation;
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(envelope.payload, {
op: "noop",
}),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -712,7 +674,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
}
async function handleCopy(request: Request, payload: TreeCommandPayload) {
const { auth, client } = await getAuthedConvexClient();
const { client } = await getAuthedConvexClient();
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
@@ -747,11 +709,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const { context, envelope, result } = await resolveTreeMutationResult<{
const mutation = await resolveTreeMutationResult<{
items: Array<{
oldId: string;
newId: string;
title?: string | null;
document?: TreeDeltaDocument | null;
}>;
}>({
request,
@@ -765,6 +728,7 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
pageId: targetParentId,
client,
});
const { context, envelope, result } = mutation;
await Promise.all(
(result.items ?? []).map(async (item) => {
@@ -772,19 +736,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
await copyMindmapFilesIfExists(item.oldId, item.newId);
}),
);
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
client,
auth,
workspaceId,
});
await recordTreeCommandSuccess({
context,
envelope,
client,
commandPayload: attachStreamDelta(
envelope.payload,
buildTreeCommandSnapshotDelta(sidebarSnapshot),
),
plan: mutation.plan,
result,
});
return NextResponse.json({
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/delete-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.delete.preflight 返回规范化 delete plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_delete_1",
traceId: "trace_filetree_delete_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.delete.preflight",
commandId: "cmd_filetree_delete_1",
functionName: "tree:fileTreeDeletePreflight",
workspaceId: "ws_1",
requestId: "req_filetree_delete_1",
traceId: "trace_filetree_delete_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreeDeletePlan: {
rowIds: ["doc:doc_1", "asset:asset_1"],
docIds: ["doc_1"],
assetIds: ["asset_1"],
assetDocumentIds: ["doc_other"],
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/delete-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
rows: [],
documentParents: [],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_delete_1",
traceId: "trace_filetree_delete_1",
plan: {
rowIds: ["doc:doc_1", "asset:asset_1"],
docIds: ["doc_1"],
assetIds: ["asset_1"],
assetDocumentIds: ["doc_other"],
},
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.delete.preflight",
payload: expect.objectContaining({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
}),
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreeDeletePreflightPayload = {
workspaceId?: string | null;
rowIds?: string[];
rows?: unknown[];
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
}
function readFileTreeDeletePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
const value = plan.argsJson.fileTreeDeletePlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreeDeletePlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreeDeletePreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
rowIds: normalizeStringArray(payload.rowIds),
rows: Array.isArray(payload.rows) ? payload.rows : [],
documentParents: Array.isArray(payload.documentParents)
? payload.documentParents.map((item) => ({
documentId: trimOrNull(item?.documentId),
parentId: trimOrNull(item?.parentId),
}))
: [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.delete.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
},
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreeDeletePlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/drop-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.drop.preflight 返回规范化 drop plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_drop_1",
traceId: "trace_filetree_drop_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.drop.preflight",
commandId: "cmd_filetree_drop_1",
functionName: "tree:fileTreeDropPreflight",
workspaceId: "ws_1",
requestId: "req_filetree_drop_1",
traceId: "trace_filetree_drop_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreeDropPlan: {
copy: false,
targetDocumentId: "doc_target",
targetMindmapId: null,
targetSubPath: null,
rowIds: ["doc:doc_1"],
docIds: ["doc_1"],
topLevelDocIds: ["doc_1"],
copyableAssetIds: [],
sourceAssetDocumentIds: [],
documentTransferPlan: {
action: "move",
targetParentId: "doc_target",
documentIds: ["doc_1"],
topLevelDocumentIds: ["doc_1"],
copyItems: [{ documentId: "doc_1", recursive: true }],
},
resourceTransferPlan: null,
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/drop-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
copy: false,
targetDocumentId: "doc_target",
targetRowId: null,
focusedRowId: null,
activeDocumentId: null,
rowIds: ["doc:doc_1"],
rows: [],
documentParents: [],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_drop_1",
traceId: "trace_filetree_drop_1",
plan: {
copy: false,
targetDocumentId: "doc_target",
targetMindmapId: null,
targetSubPath: null,
rowIds: ["doc:doc_1"],
docIds: ["doc_1"],
topLevelDocIds: ["doc_1"],
copyableAssetIds: [],
sourceAssetDocumentIds: [],
documentTransferPlan: {
action: "move",
targetParentId: "doc_target",
documentIds: ["doc_1"],
topLevelDocumentIds: ["doc_1"],
copyItems: [{ documentId: "doc_1", recursive: true }],
},
resourceTransferPlan: null,
},
});
expect(mockBuildDocumentBridgeContext).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
validateOnly: true,
dryRun: true,
}),
);
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.drop.preflight",
payload: expect.objectContaining({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
rowIds: ["doc:doc_1"],
}),
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreeDropPreflightPayload = {
workspaceId?: string | null;
copy?: boolean;
targetDocumentId?: string | null;
targetRowId?: string | null;
focusedRowId?: string | null;
activeDocumentId?: string | null;
rowIds?: string[];
rows?: unknown[];
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
}
function readFileTreeDropPlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
const value = plan.argsJson.fileTreeDropPlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreeDropPlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreeDropPreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
copy: Boolean(payload.copy),
targetDocumentId: trimOrNull(payload.targetDocumentId),
targetRowId: trimOrNull(payload.targetRowId),
focusedRowId: trimOrNull(payload.focusedRowId),
activeDocumentId: trimOrNull(payload.activeDocumentId),
rowIds: normalizeStringArray(payload.rowIds),
rows: Array.isArray(payload.rows) ? payload.rows : [],
documentParents: Array.isArray(payload.documentParents)
? payload.documentParents.map((item) => ({
documentId: trimOrNull(item?.documentId),
parentId: trimOrNull(item?.parentId),
}))
: [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.drop.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
pageId: normalizedPayload.targetDocumentId ?? undefined,
},
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreeDropPlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/paste-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.paste.preflight 返回规范化 paste plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_paste_1",
traceId: "trace_filetree_paste_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.paste.preflight",
commandId: "cmd_filetree_paste_1",
functionName: "tree:fileTreePastePreflight",
workspaceId: "ws_1",
requestId: "req_filetree_paste_1",
traceId: "trace_filetree_paste_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreePastePlan: {
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
docItems: [{ documentId: "doc_1", recursive: false }],
copyableAssetIds: ["asset_1"],
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/paste-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_1", "asset:asset_1"],
rows: [],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_paste_1",
traceId: "trace_filetree_paste_1",
plan: {
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
docItems: [{ documentId: "doc_1", recursive: false }],
copyableAssetIds: ["asset_1"],
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.paste.preflight",
payload: expect.objectContaining({
workspaceId: "ws_1",
focusedRowId: "asset-folder:mind_1",
rowIds: ["index:doc_1", "asset:asset_1"],
}),
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreePastePreflightPayload = {
workspaceId?: string | null;
targetDocumentId?: string | null;
focusedRowId?: string | null;
activeDocumentId?: string | null;
rowIds?: string[];
rows?: unknown[];
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
}
function readFileTreePastePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
const value = plan.argsJson.fileTreePastePlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreePastePlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreePastePreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
targetDocumentId: trimOrNull(payload.targetDocumentId),
focusedRowId: trimOrNull(payload.focusedRowId),
activeDocumentId: trimOrNull(payload.activeDocumentId),
rowIds: normalizeStringArray(payload.rowIds),
rows: Array.isArray(payload.rows) ? payload.rows : [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.paste.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
pageId: normalizedPayload.targetDocumentId ?? undefined,
},
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreePastePlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn(() => true);
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentCommandEnvelope = vi.fn();
const mockResolveRustBridgeCommandPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{
status:
typeof (error as { status?: unknown })?.status === "number"
? ((error as { status: number }).status ?? 500)
: 500,
},
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
mockResolveRustBridgeCommandPlan(...args),
}));
describe("/api/tree/filetree/upload-target-preflight route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReturnValue(true);
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentCommandEnvelope.mockReset();
mockResolveRustBridgeCommandPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
});
it("应通过 Rust tree.filetree.upload-target.preflight 返回规范化 upload target plan", async () => {
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_filetree_upload_target_1",
traceId: "trace_filetree_upload_target_1",
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
deploymentId: null,
projectId: null,
authToken: null,
idempotencyKey: null,
validateOnly: true,
dryRun: true,
});
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
mockResolveRustBridgeCommandPlan.mockResolvedValue({
kind: "command",
commandName: "tree.filetree.upload-target.preflight",
commandId: "cmd_filetree_upload_target_1",
functionName: "tree:fileTreeUploadTargetPreflight",
workspaceId: "ws_1",
requestId: "req_filetree_upload_target_1",
traceId: "trace_filetree_upload_target_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
fileTreeUploadTargetPlan: {
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/tree/filetree/upload-target-preflight", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: null,
activeDocumentId: "doc_active",
rows: [],
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
requestId: "req_filetree_upload_target_1",
traceId: "trace_filetree_upload_target_1",
plan: {
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
},
});
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
expect.objectContaining({
name: "tree.filetree.upload-target.preflight",
payload: expect.objectContaining({
workspaceId: "ws_fallback",
targetRowId: "asset:asset_child_1",
activeDocumentId: "doc_active",
}),
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
refs: ["file-tree-shell"],
}),
);
});
});
@@ -0,0 +1,88 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
type FileTreeUploadTargetPreflightPayload = {
workspaceId?: string | null;
targetDocumentId?: string | null;
targetRowId?: string | null;
focusedRowId?: string | null;
activeDocumentId?: string | null;
rows?: unknown[];
documentWorkspaces?: Array<{ documentId?: string | null; workspaceId?: string | null }>;
};
function trimOrNull(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readFileTreeUploadTargetPlan(
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>,
) {
const value = plan.argsJson.fileTreeUploadTargetPlan;
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Rust runtime 未返回 fileTreeUploadTargetPlan");
}
return value;
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as FileTreeUploadTargetPreflightPayload;
const workspaceId = trimOrNull(payload.workspaceId);
const normalizedPayload = {
workspaceId,
targetDocumentId: trimOrNull(payload.targetDocumentId),
targetRowId: trimOrNull(payload.targetRowId),
focusedRowId: trimOrNull(payload.focusedRowId),
activeDocumentId: trimOrNull(payload.activeDocumentId),
rows: Array.isArray(payload.rows) ? payload.rows : [],
documentWorkspaces: Array.isArray(payload.documentWorkspaces)
? payload.documentWorkspaces.map((item) => ({
documentId: trimOrNull(item?.documentId),
workspaceId: trimOrNull(item?.workspaceId),
}))
: [],
};
const context = await buildDocumentBridgeContext({
request,
workspaceId,
validateOnly: true,
dryRun: true,
});
const envelope = buildDocumentCommandEnvelope({
name: "tree.filetree.upload-target.preflight",
payload: normalizedPayload,
context,
target: {
workspaceId,
pageId: normalizedPayload.targetDocumentId ?? undefined,
},
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
refs: ["file-tree-shell"],
});
const plan = await resolveRustBridgeCommandPlan({
context,
envelope,
});
return NextResponse.json({
requestId: context.requestId,
traceId: context.traceId,
plan: readFileTreeUploadTargetPlan(plan),
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveKernelFileTreeProjection = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: () => mockIsConvexEnabled(),
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: (...args: unknown[]) =>
mockExecuteRustBridgeQueryTransport(...args),
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args),
}));
vi.mock("@/lib/server/kernel-file-tree", async () => {
const actual = await vi.importActual<typeof import("@/lib/server/kernel-file-tree")>(
"@/lib/server/kernel-file-tree",
);
return {
...actual,
resolveKernelFileTreeProjection: (...args: unknown[]) =>
mockResolveKernelFileTreeProjection(...args),
};
});
describe("/api/tree/projections/file route", () => {
beforeEach(() => {
vi.resetModules();
mockIsConvexEnabled.mockReset().mockReturnValue(true);
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
auth: {
userId: "user_1",
},
client: {
query: vi.fn(),
mutation: vi.fn(),
},
});
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
});
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
functionName: "sidebar:datasetList",
argsJson: {
workspaceId: "ws_1",
},
});
mockExecuteRustBridgeQueryTransport.mockReset().mockResolvedValue({
active_workspace_id: "ws_1",
documents: [],
media_assets: [],
mindmap_assets: [],
table_assets: [],
mindmap_asset_children: {},
});
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
projectionId: "kernel_projection:file_tree:page_root",
projection: "file_tree",
rootNodeId: "page_root",
items: [
{
rowId: "doc:page_root",
nodeId: "page_root",
projectionKind: "file_tree",
rowKind: "document",
},
{
rowId: "asset:table_1",
nodeId: "asset:table_1",
projectionKind: "file_tree",
rowKind: "asset",
},
],
edges: [],
});
mockDocumentBridgeErrorResponse.mockClear();
});
it("通过 3000 同源 route 返回 Rust file_tree 搜索 projection", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
{ method: "GET" },
),
);
expect(response.status).toBe(200);
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
depth: 3,
query: "预算",
maxResults: 12,
}),
);
const body = await response.json();
expect(body).toMatchObject({
ok: true,
result: {
projection: "file_tree",
rootNodeId: "page_root",
},
});
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
"doc:page_root",
"asset:table_1",
]);
});
it("Convex 未启用时返回 501", async () => {
mockIsConvexEnabled.mockReturnValue(false);
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1"),
);
expect(response.status).toBe(501);
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
});
});
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
export async function GET(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const url = new URL(request.url);
const workspaceId = url.searchParams.get("workspaceId")?.trim();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
try {
const { auth, client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const projection = await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor: {
actorType: "user",
actorId: auth.userId,
sessionId: null,
},
dataset,
rootNodeId: url.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(url, "depth"),
query: url.searchParams.get("query")?.trim() || null,
maxResults: readNumberParam(url, "maxResults"),
});
return NextResponse.json({
ok: true,
requestId: context.requestId,
traceId: context.traceId,
result: projection,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -20,7 +20,23 @@ describe("/api/tree/shell route", () => {
vi.stubGlobal("fetch", mockFetch);
});
it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
it("未显式 debug 时不应再代理 3104 tree shell", async () => {
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
),
);
expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
expect(response.status).toBe(404);
expect(await response.json()).toEqual({
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
});
});
it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
@@ -43,7 +59,7 @@ describe("/api/tree/shell route", () => {
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
{
method: "GET",
headers: {
@@ -54,7 +70,7 @@ describe("/api/tree/shell route", () => {
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
expect.objectContaining({
method: "GET",
headers: expect.any(Headers),
@@ -24,6 +24,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const debugEnabled =
requestUrl.searchParams.get("debug") === "1" ||
requestUrl.searchParams.get("internal") === "1" ||
process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1";
if (!debugEnabled) {
return NextResponse.json(
{
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
},
{ status: 404 },
);
}
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
@@ -458,7 +458,7 @@ describe("MoveEmbedPickerDialog", () => {
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull();
@@ -494,7 +494,7 @@ describe("MoveEmbedPickerDialog", () => {
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
).not.toBeNull();
@@ -658,7 +658,7 @@ describe("MoveEmbedPickerDialog", () => {
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
describe("sidebar file tree delete preflight source", () => {
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("preflightFileTreeDelete(");
expect(source).toContain("buildFileTreeShellDeletePreflightPayload(");
expect(source).not.toContain("computeFileTreeShellDeleteTargets(");
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
describe("sidebar file tree paste preflight source", () => {
it("rust_family 粘贴链应走 Rust paste preflight,而不是本地 shell row 语义推导", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const preflightIndex = source.indexOf("preflightFileTreePaste(");
const rustBranchStart = source.lastIndexOf("if (isRustFamilyTreeRenderer) {", preflightIndex);
const legacyBranchStart = source.indexOf("const targetDocId = inferPasteTargetDocId", preflightIndex);
expect(preflightIndex).toBeGreaterThanOrEqual(0);
expect(rustBranchStart).toBeGreaterThanOrEqual(0);
expect(legacyBranchStart).toBeGreaterThan(rustBranchStart);
const rustPasteBranch = source.slice(rustBranchStart, legacyBranchStart);
expect(rustPasteBranch).toContain("preflightFileTreePaste(");
expect(rustPasteBranch).toContain("buildFileTreeShellPastePreflightPayload(");
expect(rustPasteBranch).toContain("pastePlan.docItems");
expect(rustPasteBranch).toContain("pastePlan.resourceTransferPlan");
expect(rustPasteBranch).not.toContain("docItemsMap");
expect(rustPasteBranch).not.toContain("copyableAssetIds");
expect(source).not.toContain("getOrderedFileTreeShellRows");
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
describe("sidebar file tree selection source", () => {
it("rust_family renderer selection snapshot 只能由 filetree selection event 写入", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const writes = source.match(/setResourceRendererSelection\(/g) ?? [];
expect(writes).toHaveLength(1);
expect(source).toContain("const [resourceRendererSelection, setResourceRendererSelection]");
expect(source).toContain("const handleFileTreeShellSelectionChange = useCallback");
expect(source).toContain("materializeRendererSelectionSnapshot");
expect(source).not.toContain("selectedRowIds={resourceSelection.selectedRowIds}");
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
describe("sidebar file tree upload target preflight source", () => {
it("外部上传链应走 Rust upload target preflight,而不是在 Sidebar 解释目标行与工作区", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const handlerStart = source.indexOf("const handleResourcePaneDropFiles = useCallback");
const handlerEnd = source.indexOf("const handleResourcePaneInternalDrop = useCallback");
expect(handlerStart).toBeGreaterThanOrEqual(0);
expect(handlerEnd).toBeGreaterThan(handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
expect(handlerSource).toContain("preflightFileTreeUploadTarget(");
expect(handlerSource).toContain("buildFileTreeShellUploadTargetPreflightPayload(");
expect(handlerSource).toContain("uploadTargetPlan.workspaceId");
expect(handlerSource).toContain("uploadTargetPlan.targetDocumentId");
expect(handlerSource).toContain("uploadTargetPlan.targetMindmapId");
expect(handlerSource).not.toContain("resolveFileTreeShellMindmapTargetId");
expect(handlerSource).not.toContain("inferFileTreeShellTargetDocumentId");
expect(handlerSource).not.toContain("sidebarData.documents.find");
});
});
+341 -285
View File
@@ -54,18 +54,38 @@ import {
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useCurrentDocumentStore } from "@/store/current-document";
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows";
import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd";
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import { fetchKernelFileTreeProjection } from "@/lib/file-tree/projection-client";
import {
copyFileTreeResourceAssets,
deleteFileTreeResourceAssets,
moveFileTreeResourceAssets,
preflightFileTreeDelete,
preflightFileTreeInternalDrop,
preflightFileTreePaste,
preflightFileTreeUploadTarget,
renameFileTreeResourceAsset,
restoreFileTreeResourceAssets,
uploadFileTreeResourceAsset,
} from "@/lib/file-tree/resource-command-client";
import { buildParentById } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import {
computeFileTreeShellDeleteTargets,
buildFileTreeShellDeletePreflightPayload,
buildFileTreeShellInternalDropPreflightPayload,
buildFileTreeShellPastePreflightPayload,
buildFileTreeShellUploadTargetPreflightPayload,
buildFileTreeShellRowById,
buildFileTreeShellVisibleRowIds,
collectFileTreeShellAssetHints,
type FileTreeShellRow,
inferFileTreeShellTargetDocumentId,
getOrderedFileTreeShellRows,
resolveFileTreeShellMindmapTargetId,
} from "@/lib/file-tree/shell";
import {
createEmptyFileTreeSelectionState,
materializeRendererSelectionSnapshot,
resolveActiveFileTreeSelection,
} from "@/lib/file-tree/selection-source";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
computeTreePaneDeleteTargets,
@@ -263,11 +283,15 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
const [resourceSelection, setResourceSelection] = useState<TreePaneSelectionState>(() => ({
selectedRowIds: new Set(),
anchorRowId: null,
focusedRowId: null,
}));
const [legacyResourceSelection, setLegacyResourceSelection] = useState<TreePaneSelectionState>(
() => createEmptyFileTreeSelectionState(),
);
const [resourceRendererSelection, setResourceRendererSelection] = useState<TreePaneSelectionState>(
() => createEmptyFileTreeSelectionState(),
);
const [searchFileTreeProjection, setSearchFileTreeProjection] =
useState<KernelFileTreeProjection | null>(null);
const [searchFileTreeProjectionKey, setSearchFileTreeProjectionKey] = useState<string | null>(null);
const [sidebarHydrated, setSidebarHydrated] = useState(false);
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
@@ -297,6 +321,42 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
pageTreeFocusedDocumentIdRef.current = activeId || null;
}, [activeId]);
useEffect(() => {
const query = filter.trim();
const workspaceId = sidebarData.activeWorkspaceId?.trim();
if (!query || !workspaceId) {
setSearchFileTreeProjection(null);
setSearchFileTreeProjectionKey(null);
return;
}
const requestKey = `${workspaceId}:${query}`;
let cancelled = false;
setSearchFileTreeProjectionKey(requestKey);
setSearchFileTreeProjection(null);
void fetchKernelFileTreeProjection({
workspaceId,
query,
maxResults: 80,
})
.then((projection) => {
if (cancelled) {
return;
}
setSearchFileTreeProjection(projection);
})
.catch(() => {
if (cancelled) {
return;
}
setSearchFileTreeProjection(null);
});
return () => {
cancelled = true;
};
}, [filter, sidebarData.activeWorkspaceId]);
useEffect(() => {
const nextAssets = sidebarData.mediaAssets ?? [];
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
@@ -594,22 +654,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
const normalizedFileTreeSearchQuery = filter.trim();
const expectedSearchFileTreeProjectionKey =
normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId
? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}`
: null;
const resourceTreeShellItems = useMemo(
() =>
filter.trim().length === 0
? undefined
: filterKernelFileTreeProjectionItems({
fileTreeItems: sidebarData.kernelFileTreeProjection.items,
visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)),
expandedDocumentIds: expanded,
expandedAssetFolderIds: expandedAssetFolders,
}),
expectedSearchFileTreeProjectionKey &&
searchFileTreeProjectionKey === expectedSearchFileTreeProjectionKey
? (searchFileTreeProjection?.items ?? [])
: undefined,
[
expanded,
expandedAssetFolders,
sidebarData.kernelFileTreeProjection.items,
visibleFilteredPrivatePageRows,
filter,
expectedSearchFileTreeProjectionKey,
searchFileTreeProjection,
searchFileTreeProjectionKey,
],
);
@@ -619,8 +678,15 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
);
const effectiveResourceTreeShellItems = useMemo(
() => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items,
[resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items],
() =>
normalizedFileTreeSearchQuery.length > 0
? (resourceTreeShellItems ?? [])
: sidebarData.kernelFileTreeProjection.items,
[
normalizedFileTreeSearchQuery,
resourceTreeShellItems,
sidebarData.kernelFileTreeProjection.items,
],
);
const resourceShellVisibleRowIds = useMemo(
@@ -663,12 +729,24 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer
? resourceShellVisibleRowIds
: resourceVisibleRowIds;
const resourceSelection = useMemo(
() =>
resolveActiveFileTreeSelection({
preferRendererSnapshot: isRustFamilyTreeRenderer,
legacySelection: legacyResourceSelection,
rendererSelection: resourceRendererSelection,
}),
[isRustFamilyTreeRenderer, legacyResourceSelection, resourceRendererSelection],
);
useEffect(() => {
setResourceSelection((prev) =>
if (isRustFamilyTreeRenderer) {
return;
}
setLegacyResourceSelection((prev) =>
normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds),
);
}, [resourceSelectionVisibleRowIds]);
}, [isRustFamilyTreeRenderer, resourceSelectionVisibleRowIds]);
const docParentById = useMemo(
() =>
@@ -680,6 +758,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
),
[sidebarData.documents],
);
const documentWorkspaceById = useMemo(
() =>
new Map(
(sidebarData.documents ?? []).map((doc) => [
doc.id,
typeof doc.workspace_id === "string" ? doc.workspace_id : null,
]),
),
[sidebarData.documents],
);
const childrenCountByParentId = useMemo(() => {
const map = new Map<string | null, number>();
@@ -868,12 +956,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
}, [activeId, editorBridge, router, setOpen]);
const handleResourcePaneBlankMouseDown = useCallback(() => {
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}, []);
const handleResourceRowClick = useCallback(
(row: TreePaneRow, event: React.MouseEvent) => {
setResourceSelection((prev) =>
setLegacyResourceSelection((prev) =>
reduceTreePaneSelection(prev, {
type: "click",
rowId: row.rowId,
@@ -902,7 +990,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
);
const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
setResourceSelection((prev) => {
setLegacyResourceSelection((prev) => {
if (prev.selectedRowIds.has(row.rowId)) return prev;
return reduceTreePaneSelection(prev, {
type: "click",
@@ -928,7 +1016,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
(row: TreePaneRow, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setResourceSelection((prev) =>
setLegacyResourceSelection((prev) =>
reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
);
@@ -1047,25 +1135,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
const normalized = normalizeTreePaneSelectionForVisibleRows(
{
selectedRowIds: new Set(
payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)),
),
anchorRowId:
payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId)
? payload.anchorRowId
: null,
focusedRowId:
payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId)
? payload.focusedRowId
: null,
},
resourceShellVisibleRowIds,
setResourceRendererSelection(
materializeRendererSelectionSnapshot({
payload,
hasRowId: (rowId) => resourceShellRowById.has(rowId),
}),
);
setResourceSelection(normalized);
},
[resourceShellRowById, resourceShellVisibleRowIds],
[resourceShellRowById],
);
const handleFileTreeShellAssetOpen = useCallback(
@@ -1132,17 +1209,63 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
const targetDocId = isRustFamilyTreeRenderer
? inferFileTreeShellTargetDocumentId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceShellRowById,
activeDocId: activeId || null,
})
: inferPasteTargetDocId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
if (isRustFamilyTreeRenderer) {
let pastePlan;
try {
pastePlan = await preflightFileTreePaste(
buildFileTreeShellPastePreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
targetDocumentId: null,
focusedRowId: resourceSelection.focusedRowId,
activeDocId: activeId || null,
rowIds: payload.rowIds,
rowById: resourceShellRowById,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : "文件树粘贴预检失败";
setTimeout(() => window.alert(message), 0);
return;
}
if (pastePlan.docItems.length > 0) {
try {
await copyTreeCommand({
items: pastePlan.docItems,
targetParentId: pastePlan.targetDocumentId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴页面失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitDocumentsChanged(pastePlan.targetDocumentId);
}
if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) {
try {
await copyFileTreeResourceAssets({
assetIds: pastePlan.resourceTransferPlan.assetIds,
targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId,
targetSubPath: pastePlan.resourceTransferPlan.targetSubPath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId);
}
return;
}
const targetDocId = inferPasteTargetDocId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
return;
@@ -1151,50 +1274,28 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const docItemsMap = new Map<string, boolean>();
const copyableAssetIds: string[] = [];
if (isRustFamilyTreeRenderer) {
const rows = getOrderedFileTreeShellRows({
rowIds: payload.rowIds,
visibleRowIds: resourceShellVisibleRowIds,
rowById: resourceShellRowById,
});
const rows = payload.rowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
rows.forEach((row) => {
if (row.rowKind === "doc") {
docItemsMap.set(row.documentId, true);
return;
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
}
if (row.rowKind === "index" && !docItemsMap.has(row.documentId)) {
docItemsMap.set(row.documentId, false);
return;
}
if (row.rowKind === "asset" && row.asset && isRealFileAsset(row.asset)) {
copyableAssetIds.push(row.asset.id);
}
});
} else {
const rows = payload.rowIds
.map((rowId) => resourceRowById.get(rowId as any))
.filter(Boolean) as TreePaneRow[];
}
});
rows.forEach((row) => {
if (row.kind === "doc") {
docItemsMap.set(row.docId, true);
} else if (row.kind === "index") {
if (!docItemsMap.has(row.docId)) {
docItemsMap.set(row.docId, false);
}
}
rows
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.forEach((asset) => {
copyableAssetIds.push(asset.id);
});
rows
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.forEach((asset) => {
copyableAssetIds.push(asset.id);
});
}
if (docItemsMap.size > 0) {
try {
await copyTreeCommand({
@@ -1214,18 +1315,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
}
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "copy",
try {
await copyFileTreeResourceAssets({
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0);
});
} catch (error) {
const message = error instanceof Error ? error.message : "粘贴附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
@@ -1370,14 +1467,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const input = window.prompt("输入新文件名", asset.file_name ?? "");
if (!input || !input.trim()) return;
const newName = input.trim();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "重命名失败");
try {
await renameFileTreeResourceAsset({ assetId: asset.id, newName });
} catch (error) {
window.alert(error instanceof Error ? error.message : "重命名失败");
return;
}
await sidebarQuery.refetch();
@@ -1399,18 +1492,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
}
const target = window.prompt("输入目标页面 ID", asset.document_id);
if (!target || !target.trim()) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
try {
await moveFileTreeResourceAssets({
assetIds: [asset.id],
targetDocumentId: target.trim(),
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
});
} catch (error) {
window.alert(error instanceof Error ? error.message : "移动失败");
return;
}
await sidebarQuery.refetch();
@@ -1475,14 +1563,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
}
if (fileAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
try {
await deleteFileTreeResourceAssets(fileAssetIds);
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
return;
}
}
@@ -1504,14 +1588,35 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
);
const handleDeleteResourceSelection = useCallback(async () => {
const shellDeleteTargets = isRustFamilyTreeRenderer
? computeFileTreeShellDeleteTargets({
visibleRowIds: resourceShellVisibleRowIds,
rowById: resourceShellRowById,
selectedRowIds: resourceSelection.selectedRowIds,
parentById: docParentById,
})
: null;
const selectedRowIds = Array.from(resourceSelection.selectedRowIds);
let shellDeleteTargets: {
docIds: string[];
assetIds: string[];
assetHints: MediaAsset[];
} | null = null;
if (isRustFamilyTreeRenderer) {
try {
const deletePlan = await preflightFileTreeDelete(
buildFileTreeShellDeletePreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
rowIds: selectedRowIds,
rowById: resourceShellRowById,
parentById: docParentById,
}),
);
shellDeleteTargets = {
docIds: deletePlan.docIds,
assetIds: deletePlan.assetIds,
assetHints: collectFileTreeShellAssetHints({
rowById: resourceShellRowById,
assetIds: deletePlan.assetIds,
}),
};
} catch (error) {
window.alert(error instanceof Error ? error.message : "文件树删除预检失败");
return;
}
}
const legacyDeleteTargets = !isRustFamilyTreeRenderer
? computeTreePaneDeleteTargets({
visibleRows: resourceRows,
@@ -1595,7 +1700,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
await refreshTree();
setContextMenu(null);
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
@@ -1605,11 +1712,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
isRustFamilyTreeRenderer,
resourceRows,
resourceShellRowById,
resourceShellVisibleRowIds,
resourceSelection.selectedRowIds,
handleDeleteAssets,
refreshTree,
router,
sidebarData.activeWorkspaceId,
]);
const handleResizeStart = useCallback(
@@ -1764,59 +1871,43 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
void (async () => {
const droppedFiles = Array.from(payload.files ?? []);
if (droppedFiles.length === 0) return;
const targetRow =
payload.targetRowId
? (resourceShellRowById.get(payload.targetRowId) ?? null)
: null;
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const inferredTargetDocId =
payload.targetDocumentId ||
inferFileTreeShellTargetDocumentId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceShellRowById,
activeDocId: activeId || null,
}) ||
"";
if (!inferredTargetDocId) {
setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0);
let uploadTargetPlan;
try {
uploadTargetPlan = await preflightFileTreeUploadTarget(
buildFileTreeShellUploadTargetPreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
targetDocumentId: payload.targetDocumentId,
targetRowId: payload.targetRowId,
focusedRowId: resourceSelection.focusedRowId,
activeDocId: activeId || null,
rowById: resourceShellRowById,
documentWorkspaceById,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : "文件树上传目标预检失败";
setTimeout(() => window.alert(message), 0);
return;
}
const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null;
const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? "";
if (!workspaceId) {
setTimeout(() => window.alert("无法识别当前工作区,上传失败"), 0);
return;
if (uploadTargetPlan.targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId));
}
const errors: string[] = [];
for (const file of droppedFiles) {
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", inferredTargetDocId);
if (targetMindmapId) {
form.append("mindmapId", targetMindmapId);
}
const resp = await fetch("/api/media/upload", { method: "POST", body: form });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`);
continue;
}
const payload = (await resp.json()) as { asset?: MediaAsset };
const payload = await uploadFileTreeResourceAsset({
file,
workspaceId: uploadTargetPlan.workspaceId,
documentId: uploadTargetPlan.targetDocumentId,
mindmapId: uploadTargetPlan.targetMindmapId,
});
if (payload.asset?.id) {
emitAssetsChanged(inferredTargetDocId, payload.asset);
emitAssetsChanged(uploadTargetPlan.targetDocumentId, payload.asset);
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
if (inferredTargetDocId === activeId && !targetMindmapId) {
if (uploadTargetPlan.targetDocumentId === activeId && !uploadTargetPlan.targetMindmapId) {
editorBridge?.insertMediaAsset?.(payload.asset);
}
} else {
@@ -1843,11 +1934,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
},
[
activeId,
documentWorkspaceById,
editorBridge,
resourceShellRowById,
resourceSelection.focusedRowId,
sidebarData.activeWorkspaceId,
sidebarData.documents,
sidebarQuery,
],
);
@@ -1862,64 +1953,44 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
copy: boolean;
}) => {
void (async () => {
const targetRow =
payload.targetRowId
? (resourceShellRowById.get(payload.targetRowId) ?? null)
: null;
const targetDocId =
payload.targetDocumentId ??
targetRow?.documentId ??
inferFileTreeShellTargetDocumentId({
focusedRowId: resourceSelection.focusedRowId,
rowById: resourceShellRowById,
activeDocId: activeId || null,
});
if (!targetDocId) {
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
const preflightPayload = buildFileTreeShellInternalDropPreflightPayload({
workspaceId: sidebarData.activeWorkspaceId ?? null,
copy: payload.copy,
targetDocumentId: payload.targetDocumentId,
targetRowId: payload.targetRowId,
rowIds: payload.rowIds,
rowById: resourceShellRowById,
focusedRowId: resourceSelection.focusedRowId,
activeDocId: activeId || null,
parentById: docParentById,
});
let dropPlan;
try {
dropPlan = await preflightFileTreeInternalDrop(preflightPayload);
} catch (error) {
const message = error instanceof Error ? error.message : "文件树拖放预检失败";
setTimeout(() => window.alert(message), 0);
return;
}
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
const {
targetDocumentId: targetDocId,
targetMindmapId,
documentTransferPlan,
resourceTransferPlan,
sourceAssetDocumentIds,
} = dropPlan;
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const uniqueRowIds: string[] = [];
const seen = new Set<string>();
payload.rowIds.forEach((id) => {
if (!id || seen.has(id)) return;
seen.add(id);
uniqueRowIds.push(id);
});
const rows = uniqueRowIds
.map((rowId) => resourceShellRowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row));
const docIds = rows.filter((row) => row.rowKind === "doc").map((row) => row.documentId);
const assetRows = rows.filter(
(row): row is FileTreeShellRow & { rowKind: "asset"; asset: MediaAsset } =>
row.rowKind === "asset" && Boolean(row.asset),
);
const copyableAssetIds = assetRows
.map((row) => row.asset)
.filter((asset) => isRealFileAsset(asset))
.map((asset) => asset.id);
if (docIds.length === 0 && copyableAssetIds.length === 0) {
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
return;
}
if (payload.copy) {
if (docIds.length > 0) {
if (documentTransferPlan && documentTransferPlan.copyItems.length > 0) {
try {
await copyTreeCommand({
items: docIds.map((documentId) => ({ documentId, recursive: true })),
targetParentId: targetDocId,
items: documentTransferPlan.copyItems,
targetParentId: documentTransferPlan.targetParentId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "复制页面失败";
@@ -1930,20 +2001,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "copy",
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
targetSubPath,
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0);
if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) {
try {
await copyFileTreeResourceAssets({
assetIds: resourceTransferPlan.assetIds,
targetDocumentId: resourceTransferPlan.targetDocumentId,
targetSubPath: resourceTransferPlan.targetSubPath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "复制附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
@@ -1953,23 +2020,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
if (topLevelDocIds.length > 0) {
if (documentTransferPlan && documentTransferPlan.topLevelDocumentIds.length > 0) {
const topLevelDocIds = documentTransferPlan.topLevelDocumentIds;
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
setTree((prev) => {
let next = prev;
topLevelDocIds.forEach((id, offset) => {
next = moveLocalNode(next, id, targetDocId, baseIndex + offset);
next = moveLocalNode(next, id, documentTransferPlan.targetParentId, baseIndex + offset);
});
return next;
});
setExpanded((prev) => new Set(prev).add(targetDocId));
setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId));
try {
for (let i = 0; i < topLevelDocIds.length; i += 1) {
await moveDocumentCommand({
documentId: topLevelDocIds[i],
parentId: targetDocId,
parentId: documentTransferPlan.targetParentId,
position: baseIndex + i,
});
}
@@ -1983,29 +2050,20 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
emitDocumentsChanged(targetDocId);
}
if (copyableAssetIds.length > 0) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: copyableAssetIds,
targetDocumentId: targetDocId,
targetSubPath,
}),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0);
if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) {
try {
await moveFileTreeResourceAssets({
assetIds: resourceTransferPlan.assetIds,
targetDocumentId: resourceTransferPlan.targetDocumentId,
targetSubPath: resourceTransferPlan.targetSubPath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "移动附件失败";
setTimeout(() => window.alert(message), 0);
return;
}
await sidebarQuery.refetch();
const sourceDocIds = new Set(
assetRows
.map((row) => row.asset?.document_id ?? null)
.filter((documentId): documentId is string => Boolean(documentId)),
);
sourceDocIds.forEach((id) => emitAssetsChanged(id));
sourceAssetDocumentIds.forEach((id) => emitAssetsChanged(id));
emitAssetsChanged(targetDocId);
}
})();
@@ -2013,6 +2071,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[
childrenCountByParentId,
docParentById,
sidebarData.activeWorkspaceId,
resourceSelection.focusedRowId,
activeId,
resourceShellRowById,
@@ -2091,12 +2150,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
try {
await handleDeleteAssets(uniqueAssetIds, assetHint);
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets],
[handleDeleteAssets, isRustFamilyTreeRenderer, mediaAssets, mindmapAssets, tableAssets],
);
const handleConvertToChild = useCallback(
@@ -2182,14 +2243,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
null;
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore", assetIds: [assetId] }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "恢复附件失败,请稍后再试");
try {
await restoreFileTreeResourceAssets([assetId]);
} catch (error) {
window.alert(error instanceof Error ? error.message : "恢复附件失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
@@ -2822,7 +2879,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
rows={isRustFamilyTreeRenderer ? undefined : resourceRows}
treeShellItems={effectiveResourceTreeShellItems}
activeId={activeId}
selectedRowIds={resourceSelection.selectedRowIds}
onRowClick={handleResourceRowClick}
onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
onRowContextMenu={handleResourceRowContextMenu}
@@ -13,6 +13,8 @@ export type TreeRendererFamily = "react" | "rust_family";
export type TreeShellHostMode = "page" | "filetree" | "picker";
const RUST_RENDERER_CONTRACT = "rust_renderer_input_v1";
export type TreeShellPickerCommand = {
kind: "next" | "previous" | "home" | "end" | "pick";
seq: number;
@@ -118,8 +120,9 @@ export function TreeShellHost({
const useRustHost = rendererFamily === "rust_family";
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
const rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined;
const implementation = useIframeHost
? "mnote_web_iframe_proxy"
? "rust_inline_compat_host"
: fallbackImplementation ??
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
@@ -130,6 +133,7 @@ export function TreeShellHost({
data-renderer-family={rendererFamily}
data-tree-host-kind={hostKind}
data-tree-host-implementation={implementation}
data-tree-renderer-contract={rendererContract}
data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined}
className={cn(className)}
>
@@ -139,6 +143,7 @@ export function TreeShellHost({
data-tree-host-mode={mode}
data-tree-host-kind="rust_family"
data-tree-host-implementation={implementation}
data-tree-renderer-contract={rendererContract}
className="contents"
>
{useIframeHost && workspaceId ? (
@@ -17,6 +17,24 @@ import {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function readTreeShellState(srcDoc: string | null | undefined) {
const match = (srcDoc ?? "").match(
/<script id="tree-shell-state" type="application\/json">([^<]*)<\/script>/,
);
if (!match) {
throw new Error("missing tree shell state");
}
return JSON.parse(match[1] ?? "{}") as {
items?: unknown[];
rendererInput?: {
projectionItemIds?: string[];
expandedIds?: string[];
activePickerItem?: string | null;
excludedPickerIds?: string[];
};
};
}
describe("tree-shell-iframe-host", () => {
let container: HTMLDivElement;
let root: Root;
@@ -61,6 +79,33 @@ describe("tree-shell-iframe-host", () => {
expect(url.searchParams.get("host")).toBe("tree-picker-surface");
});
it("未提供 inline projection 时也应使用本地 srcDoc,避免默认回源 3104 显示 fetch failed", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch failed"));
await act(async () => {
root.render(
<TreeShellIframeHost
mode="page"
surfaceTestId="sidebar-page-tree-shell"
workspaceId="ws_1"
activeDocumentId="doc_1"
/>,
);
});
await act(async () => {
await Promise.resolve();
});
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("src")).toBeNull();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-page-renderer="initial_v1"');
expect(iframe?.getAttribute("srcdoc")).not.toContain("fetch failed");
});
it("应能把 picker 搜索结果转换为 inline shell items 并注入到 HTML", () => {
const pickerItems = buildTreeShellInlinePickerItems([
{ kind: "doc", id: "doc_target", title: "目标页面", depth: 0 },
@@ -179,12 +224,19 @@ describe("tree-shell-iframe-host", () => {
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeActiveDom();");
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_page_focus_keyboard_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
expect.objectContaining({ nodeId: "doc_parent" }),
]);
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
});
@@ -256,7 +308,8 @@ describe("tree-shell-iframe-host", () => {
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([]);
});
it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => {
@@ -347,9 +400,33 @@ describe("tree-shell-iframe-host", () => {
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
expect(fetchMock).not.toHaveBeenCalled();
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"');
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-filetree-renderer="initial_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-rendered-row="filetree"');
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialFileTree");
expect(iframe?.getAttribute("srcdoc")).toContain("const usedRustInitialRenderer = hydrateInitialRenderer();");
expect(iframe?.getAttribute("srcdoc")).toContain("patchFileTreeActiveDom();");
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "filetree" && usedRustInitialRenderer) {patchFileTreeActiveDom();syncFileTreeSelectionDom();return;}');
expect(iframe?.getAttribute("srcdoc")).toContain('"rendererInput"');
expect(iframe?.getAttribute("srcdoc")).toContain('"mode":"fileTree"');
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);");
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction");
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection =");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds");
expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult");
expect(iframe?.getAttribute("srcdoc")).toContain("const selectFileTreeContextRow = (rowId) =>");
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
const state = readTreeShellState(iframe?.getAttribute("srcdoc"));
expect(state.items).toEqual([
expect.objectContaining({ rowId: "doc:doc_a", nodeId: "doc_a" }),
expect.objectContaining({ rowId: "asset:asset_pdf", nodeId: "asset:asset_pdf" }),
]);
expect(state.rendererInput?.projectionItemIds).toEqual(["doc:doc_a", "asset:asset_pdf"]);
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
});
@@ -370,6 +447,7 @@ describe("tree-shell-iframe-host", () => {
workspaceId="ws_1"
activeDocumentId="doc_1"
activePickerItemKey="doc_1"
excludeIds={["doc_hidden"]}
pickerItems={pickerItems}
/>,
);
@@ -380,6 +458,20 @@ describe("tree-shell-iframe-host", () => {
});
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-picker-renderer="initial_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-rendered-row="picker"');
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialPickerTree");
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
expect.objectContaining({ nodeId: "doc_1" }),
]);
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).rendererInput?.activePickerItem).toBe("doc_1");
expect(iframe?.getAttribute("srcdoc")).toContain('"activePickerItem":"doc_1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"excludedPickerIds":["doc_hidden"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_picker_state_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction");
expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem");
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds");
const postMessage = vi.fn();
Object.defineProperty(iframe, "contentWindow", {
configurable: true,
File diff suppressed because it is too large Load Diff
@@ -54,20 +54,25 @@ describe("tree-shell-surface", () => {
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
it("page tree surface 在 rust_family 下应把 focusedDocumentId 透传到 iframe", () => {
it("page tree surface 在 rust_family 下应把 focusedDocumentId 作为宿主状态暴露,并使用 postMessage patch 同步 iframe", () => {
renderPageSurface("rust_family", "doc_focus");
const iframe = container.querySelector(
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
) as HTMLIFrameElement | null;
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(iframe?.getAttribute("src")).toContain("focusedDocumentId=doc_focus");
expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus");
expect(iframe?.getAttribute("src")).toBeNull();
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedDocumentId":"doc_focus"');
});
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
@@ -90,7 +95,7 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
});
@@ -129,7 +134,6 @@ describe("tree-shell-surface", () => {
treeShellEnabled
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
@@ -147,7 +151,9 @@ describe("tree-shell-surface", () => {
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
@@ -163,7 +169,6 @@ describe("tree-shell-surface", () => {
treeShellEnabled={false}
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
@@ -174,7 +179,7 @@ describe("tree-shell-surface", () => {
});
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
});
@@ -189,7 +194,6 @@ describe("tree-shell-surface", () => {
treeShellEnabled
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
@@ -218,7 +222,6 @@ describe("tree-shell-surface", () => {
treeShellEnabled
rows={[]}
activeId=""
selectedRowIds={new Set<string>()}
onRowClick={() => undefined}
onRowDoubleClick={() => undefined}
onRowContextMenu={() => undefined}
@@ -309,7 +312,9 @@ describe("tree-shell-surface", () => {
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
expect(onPick).not.toHaveBeenCalled();
@@ -339,7 +344,7 @@ describe("tree-shell-surface", () => {
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
expect(rustHost).not.toBeNull();
expect(iframe).not.toBeNull();
@@ -46,7 +46,6 @@ type SidebarFileTreeSurfaceProps = {
rows?: FileTreeRow[];
treeShellItems?: KernelFileTreeProjectionItem[];
activeId: string;
selectedRowIds: Set<string>;
className?: string;
onRowClick: (row: FileTreeRow, event: MouseEvent) => void;
onRowDoubleClick: (row: FileTreeRow, event: MouseEvent) => void;
@@ -0,0 +1,200 @@
import { describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("server-only", () => ({}), { virtual: true });
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
documents: {
getMeta: "documents:getMeta",
},
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
resolveRustBridgeQueryPlan: vi.fn(),
executeRustBridgeQueryTransport: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
}));
vi.mock("@/lib/blocks", () => ({
findBlockInTree: vi.fn(),
getBlocksFromDocumentContent: vi.fn(),
removeBlockSubtree: vi.fn(),
replaceBlockInTree: vi.fn(),
withBlocksWrittenBack: vi.fn(),
}));
describe("blocks/block-command-adapter", () => {
it("executeBlockPatchCommand 应通过 Rust artifact writer 记录外层 blocks.patch", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
executeRustBridgeQueryTransport,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
const {
getBlocksFromDocumentContent,
replaceBlockInTree,
withBlocksWrittenBack,
} = await import("@/lib/blocks");
const { executeBlockPatchCommand } = await import("./block-command-adapter");
const query = vi.fn(async (name: string) => {
if (name === "documents:getMeta") {
return {
id: "doc_1",
workspace_id: "ws_1",
embed_default_block_id: null,
};
}
return null;
});
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
query,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeQueryPlan).mockResolvedValue({
kind: "query",
queryName: "documents.content.get",
functionName: "documents:getContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{\"kind\":\"query\"}",
argsJson: {
id: "doc_1",
},
});
vi.mocked(executeRustBridgeQueryTransport).mockResolvedValue({
content: { type: "doc" },
revision: 7,
conflict_detection_key: "doc_1:7",
});
vi.mocked(getBlocksFromDocumentContent).mockReturnValue([{ id: "blk_1" }] as never);
vi.mocked(replaceBlockInTree).mockReturnValue({
ok: true,
nextBlocks: [{ id: "blk_1", type: "paragraph" }],
} as never);
vi.mocked(withBlocksWrittenBack).mockReturnValue({
type: "doc",
content: [{ id: "blk_1", type: "paragraph" }],
} as never);
vi.mocked(resolveRustBridgeCommandPlan)
.mockResolvedValueOnce({
kind: "command",
commandName: "blocks.patch",
commandId: "cmd_block_patch_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
blockId: "blk_1",
nextBlock: { id: "blk_1", type: "paragraph" },
},
})
.mockResolvedValueOnce({
kind: "command",
commandName: "documents.save",
commandId: "cmd_save_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
content: { type: "doc", content: [{ id: "blk_1", type: "paragraph" }] },
expectedRevision: 7,
conflictDetectionKey: "doc_1:7",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 8,
conflict_detection_key: "doc_1:8",
});
const result = await executeBlockPatchCommand({
request: new Request("http://127.0.0.1:3000/api/blocks/patch", {
method: "POST",
}),
sourceDocumentId: "doc_1",
workspaceId: "ws_1",
blockId: "blk_1",
nextBlock: {
id: "blk_1",
type: "paragraph",
},
});
expect(result.commandName).toBe("blocks.patch");
expect(result.result).toEqual({
revision: 8,
conflict_detection_key: "doc_1:8",
});
expect(vi.mocked(resolveRustBridgeCommandPlan).mock.calls).toHaveLength(2);
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(1);
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: expect.objectContaining({
workspaceId: "ws_1",
}),
envelope: expect.objectContaining({
name: "blocks.patch",
}),
plan: expect.objectContaining({
commandName: "blocks.patch",
}),
result: {
revision: 8,
conflict_detection_key: "doc_1:8",
},
});
});
});
@@ -23,7 +23,7 @@ import {
type BridgeContext,
type CommandEnvelope,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import { recordRustBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
executeRustBridgeQueryTransport,
@@ -143,16 +143,19 @@ async function resolveBlockCommandEnvelope<TPayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}) {
await resolveRustBridgeCommandPlan({
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
} satisfies BlockCommandMeta;
meta: {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
} satisfies BlockCommandMeta,
plan,
};
}
async function executeDocumentSaveTransport(input: {
@@ -196,10 +199,12 @@ async function executeDocumentSaveTransport(input: {
plan,
});
if (input.recordArtifacts !== false) {
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client: input.client,
context,
envelope,
plan,
result,
});
}
return {
@@ -290,7 +295,7 @@ export async function executeBlockPatchCommand(input: {
blockId,
},
});
const meta = await resolveBlockCommandEnvelope({
const { meta, plan } = await resolveBlockCommandEnvelope({
context,
envelope,
});
@@ -304,10 +309,12 @@ export async function executeBlockPatchCommand(input: {
conflictDetectionKey: state.conflictDetectionKey,
recordArtifacts: false,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: save.result,
});
return {
...meta,
@@ -373,7 +380,7 @@ export async function executeBlockMoveCommand(input: {
blockId,
},
});
const meta = await resolveBlockCommandEnvelope({
const { meta, plan } = await resolveBlockCommandEnvelope({
context,
envelope,
});
@@ -397,18 +404,21 @@ export async function executeBlockMoveCommand(input: {
conflictDetectionKey: targetState.conflictDetectionKey,
recordArtifacts: false,
});
await recordBridgeCommandArtifacts({
const moveResult = {
ok: true,
sourceRevision: sourceSave.result.revision ?? null,
targetRevision: targetSave.result.revision ?? null,
};
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: moveResult,
});
return {
...meta,
result: {
ok: true,
sourceRevision: sourceSave.result.revision ?? null,
targetRevision: targetSave.result.revision ?? null,
},
result: moveResult,
} satisfies BlockCommandResult<{
ok: boolean;
sourceRevision: number | null;
@@ -487,7 +497,7 @@ export async function executeBlockEmbedCommand(input: {
blockId,
},
});
const meta = await resolveBlockCommandEnvelope({
const { meta, plan } = await resolveBlockCommandEnvelope({
context,
envelope,
});
@@ -501,18 +511,21 @@ export async function executeBlockEmbedCommand(input: {
conflictDetectionKey: targetState.conflictDetectionKey,
recordArtifacts: false,
});
await recordBridgeCommandArtifacts({
const embedResult = {
ok: true,
revision: save.result.revision ?? null,
referenceBlockId: String(referenceBlock.id),
};
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: embedResult,
});
return {
...meta,
result: {
ok: true,
revision: save.result.revision ?? null,
referenceBlockId: String(referenceBlock.id),
},
result: embedResult,
} satisfies BlockCommandResult<{
ok: boolean;
revision: number | null;
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
documents: {
getContent: "documents:getContent",
},
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
resolveRustBridgeQueryPlan: vi.fn(),
executeRustBridgeQueryTransport: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
}));
vi.mock("@/lib/document-content", () => ({
extractBlocksFromContent: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
describe("documents/block-command-adapter", () => {
it("executeBlockPatchBridgeCommand 应改走 Rust artifact writer", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
recordBridgeCommandArtifacts,
recordRustBridgeCommandArtifacts,
} = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
const { extractBlocksFromContent } = await import("@/lib/document-content");
const { executeBlockPatchBridgeCommand } = await import("./block-command-adapter");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
query: vi.fn().mockResolvedValue({
content: { type: "doc" },
}),
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(extractBlocksFromContent).mockReturnValue([
{
id: "blk_1",
type: "paragraph",
},
]);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "blocks.patch",
commandId: "cmd_block_patch_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
blockId: "blk_1",
nextBlock: {
id: "blk_1",
type: "heading",
},
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 8,
conflict_detection_key: "doc_1:8",
});
const result = await executeBlockPatchBridgeCommand({
request: new Request("http://127.0.0.1:3000/api/documents/blocks/patch", {
method: "POST",
}),
sourceDocumentId: "doc_1",
workspaceId: "ws_1",
blockId: "blk_1",
nextBlock: {
id: "blk_1",
type: "heading",
},
});
expect(result.commandName).toBe("blocks.patch");
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: expect.objectContaining({
workspaceId: "ws_1",
}),
envelope: expect.objectContaining({
name: "blocks.patch",
}),
plan: expect.objectContaining({
commandName: "blocks.patch",
}),
result: {
revision: 8,
conflict_detection_key: "doc_1:8",
},
});
expect(recordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
@@ -8,8 +8,8 @@ import {
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
@@ -164,11 +164,17 @@ export async function executeBlockPatchBridgeCommand(input: {
throw new Error("块不存在或无权限");
}
try {
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({ context, envelope });
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
@@ -217,8 +223,14 @@ export async function executeBlockMoveBridgeCommand(input: {
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await recordBridgeCommandArtifacts({ context, envelope });
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
@@ -276,8 +288,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await recordBridgeCommandArtifacts({ context, envelope });
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
import type { BridgeContext, CommandEnvelope } from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
vi.mock("@/lib/convex/api", () => ({
api: {
bridgeLogs: {
recordCommandLog: "bridgeLogs.recordCommandLog",
recordDomainEvent: "bridgeLogs.recordDomainEvent",
},
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge", () => ({
DocumentBridgeError: class DocumentBridgeError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string,
public readonly details?: unknown,
) {
super(message);
this.name = "DocumentBridgeError";
}
},
}));
const context: BridgeContext = {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
};
const envelope: CommandEnvelope<{ documentId: string }> = {
name: "tree.node.archive",
commandId: "cmd_1",
idempotencyKey: "idem_1",
actor: context.actor,
source: context.source,
target: {
workspaceId: "ws_1",
pageId: "page_1",
},
payload: {
documentId: "page_1",
},
reason: null,
refs: ["test"],
dryRun: false,
validateOnly: false,
};
describe("bridge-log", () => {
it("记录成功 artifact 时应把 streamDelta 同步写入 domain event payload", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
await recordBridgeCommandArtifacts({
context,
envelope,
client: { mutation } as unknown as ConvexHttpClient,
now: "2026-04-26T00:00:00.000Z",
domainEventType: "tree.node.archived",
commandPayload: {
documentId: "page_1",
streamDelta: {
op: "remove_document",
documentId: "page_1",
},
},
});
expect(mutation).toHaveBeenNthCalledWith(
2,
"bridgeLogs.recordDomainEvent",
expect.objectContaining({
eventType: "tree.node.archived",
payload: expect.objectContaining({
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.node.archived",
aggregate: {
type: "page",
id: "page_1",
},
command_id: "cmd_1",
command_name: "tree.node.archive",
command: {
id: "cmd_1",
name: "tree.node.archive",
idempotencyKey: "idem_1",
},
trace: {
requestId: "req_1",
traceId: "trace_1",
},
error: null,
streamDelta: {
op: "remove_document",
documentId: "page_1",
},
}),
}),
);
});
it("记录 artifact 时应优先消费 Rust domainEventPlan", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
await recordBridgeCommandArtifacts({
context,
envelope,
client: { mutation } as unknown as ConvexHttpClient,
now: "2026-04-26T00:00:00.000Z",
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDelta: {
op: "move_document",
documentId: "page_1",
parentId: "parent_1",
sortOrder: 0,
},
},
domainEventType: "legacy.should_not_win",
commandPayload: {
documentId: "page_1",
},
});
expect(mutation).toHaveBeenNthCalledWith(
2,
"bridgeLogs.recordDomainEvent",
expect.objectContaining({
eventType: "tree.subtree.moved",
payload: expect.objectContaining({
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDelta: {
op: "move_document",
documentId: "page_1",
parentId: "parent_1",
sortOrder: 0,
},
}),
}),
);
});
});
+79 -8
View File
@@ -6,6 +6,7 @@ import {
type CommandEnvelope,
} from "@/lib/documents/bridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
import type { ConvexHttpClient } from "convex/browser";
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
@@ -19,11 +20,74 @@ function normalizeWorkspaceId(context: BridgeContext, target?: BridgeTarget | nu
return target?.workspaceId?.trim() || context.workspaceId?.trim() || null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readStreamDelta(payload: unknown): unknown {
if (!isRecord(payload)) {
return null;
}
return payload.streamDelta ?? payload.stream_delta ?? null;
}
function normalizeDomainEventType(raw: unknown): string | null {
if (typeof raw !== "string") {
return null;
}
const trimmed = raw.trim();
return trimmed || null;
}
function buildTreeDomainEventPayload(input: {
context: BridgeContext;
commandName: string;
commandId: string;
idempotencyKey?: string | null;
eventType: string;
aggregateType: string;
aggregateId: string;
streamDelta: unknown;
error?: string | null;
}) {
const payload: Record<string, unknown> = {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: input.eventType,
aggregate: {
type: input.aggregateType,
id: input.aggregateId,
},
trace: {
requestId: input.context.requestId,
traceId: input.context.traceId,
},
command: {
id: input.commandId,
name: input.commandName,
idempotencyKey: input.idempotencyKey ?? null,
},
error: input.error ?? null,
// 兼容既有观测与 SSE 解析字段,正式消费者应优先使用上面的结构化字段。
request_id: input.context.requestId,
trace_id: input.context.traceId,
command_id: input.commandId,
command_name: input.commandName,
idempotency_key: input.idempotencyKey ?? null,
};
if (input.streamDelta) {
payload.streamDelta = input.streamDelta;
}
return payload;
}
export async function recordBridgeCommandArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client?: ConvexHttpClient;
commandPayload?: unknown;
domainEventPlan?: RustTreeDomainEventPlan | null;
domainEventType?: string | null;
status?: BridgeCommandLogStatus;
eventStatus?: BridgeDomainEventStatus;
error?: string | null;
@@ -44,6 +108,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
const aggregateId =
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
const streamDelta = input.domainEventPlan?.streamDelta ?? readStreamDelta(payload);
const eventType =
normalizeDomainEventType(input.domainEventPlan?.eventType) ??
normalizeDomainEventType(input.domainEventType) ?? `${input.envelope.name}.requested`;
await client.mutation(api.bridgeLogs.recordCommandLog, {
workspaceId,
@@ -75,20 +143,23 @@ export async function recordBridgeCommandArtifacts<T>(input: {
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandLogId,
eventType: `${input.envelope.name}.requested`,
eventType,
aggregateType,
aggregateId,
eventVersion: 1,
status: eventStatus,
actorType: input.context.actor.actorType,
payload: {
request_id: input.context.requestId,
trace_id: input.context.traceId,
command_id: input.envelope.commandId,
command_name: input.envelope.name,
idempotency_key: input.envelope.idempotencyKey,
payload: buildTreeDomainEventPayload({
context: input.context,
commandName: input.envelope.name,
commandId: input.envelope.commandId,
idempotencyKey: input.envelope.idempotencyKey,
eventType,
aggregateType,
aggregateId,
streamDelta,
error: input.error ?? null,
},
}),
createdAt: now,
});
}
+208 -39
View File
@@ -48,6 +48,7 @@ vi.mock("@/lib/convex/route", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
@@ -478,14 +479,43 @@ describe("documents bridge helpers", () => {
});
it("executeMetadataBridgeCommand routes options update through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.options.update",
commandId: "cmd_options_1",
functionName: "documents:updateOptions",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
workspaceId: "ws_1",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const result = await executeMetadataBridgeCommand({
context: mockContext,
@@ -505,31 +535,119 @@ describe("documents bridge helpers", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
id: "doc_1",
options: {
wideLayout: undefined,
smallText: undefined,
showHeadingNumbers: undefined,
showToc: true,
showStructure: undefined,
protectEditing: undefined,
showWordCount: undefined,
collapseBacklinks: undefined,
pageFont: undefined,
layoutDensity: "compact",
hideChildPages: undefined,
showBlockRefCount: undefined,
embedDefaultBlockId: null,
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.options.update",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.options.update",
}),
plan: expect.objectContaining({
commandName: "documents.options.update",
functionName: "documents:updateOptions",
}),
result: {
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
},
});
expect(result.commandName).toBe("documents.options.update");
});
it("executeMetadataBridgeCommand routes stats update through rust runtime", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation: vi.fn().mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
}),
} as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.stats.update",
commandId: "cmd_stats_1",
functionName: "documents:updateStats",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
workspaceId: "ws_1",
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const result = await executeMetadataBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.stats.update",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
stats: {
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
},
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.stats.update",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.stats.update",
}),
plan: expect.objectContaining({
commandName: "documents.stats.update",
functionName: "documents:updateStats",
}),
result: {
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
},
});
expect(result.commandName).toBe("documents.stats.update");
});
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -540,7 +658,7 @@ describe("documents bridge helpers", () => {
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.save",
@@ -563,7 +681,7 @@ describe("documents bridge helpers", () => {
revision: 7,
conflict_detection_key: "conflict_1",
});
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
const previousBridgeArtifactCalls = vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length;
const payload = buildDocumentSavePayload({
documentId: "doc_1",
@@ -603,16 +721,25 @@ describe("documents bridge helpers", () => {
},
}),
});
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
expect(vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length).toBe(
previousBridgeArtifactCalls + 1,
);
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.save",
target: { workspaceId: "ws_1", pageId: "doc_1" },
payload,
}),
plan: expect.objectContaining({
commandName: "documents.save",
functionName: "documents:updateContent",
}),
result: {
revision: 7,
conflict_detection_key: "conflict_1",
},
});
expect(result.requestId).toBe("req_1");
expect(result.traceId).toBe("trace_1");
@@ -680,7 +807,7 @@ describe("documents bridge helpers", () => {
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -691,7 +818,7 @@ describe("documents bridge helpers", () => {
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.create",
@@ -746,12 +873,20 @@ describe("documents bridge helpers", () => {
functionName: "documents:createWithParentReference",
}),
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.create",
}),
plan: expect.objectContaining({
commandName: "documents.create",
functionName: "documents:createWithParentReference",
}),
result: {
id: "doc_1",
title: "无标题",
},
});
expect(result.result).toEqual({
id: "doc_1",
@@ -760,13 +895,39 @@ describe("documents bridge helpers", () => {
});
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "media.assets.replace_storage",
commandId: "cmd_asset_1",
functionName: "mediaAssets:replaceStorageFromUpload",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
userId: "user_1",
id: "asset_1",
documentId: "doc_1",
workspaceId: "ws_1",
storageId: "storage_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
fileUrl: "https://example.com/file.docx",
});
await executeMediaAssetWritebackBridgeCommand({
client: {
mutation,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
context: mockContext,
envelope: buildDocumentCommandEnvelope({
@@ -783,18 +944,26 @@ describe("documents bridge helpers", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
userId: "user_1",
id: "asset_1",
storageId: "storage_1",
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "media.assets.replace_storage",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "media.assets.replace_storage",
}),
plan: expect.objectContaining({
commandName: "media.assets.replace_storage",
functionName: "mediaAssets:replaceStorageFromUpload",
}),
result: {
ok: true,
fileUrl: "https://example.com/file.docx",
},
});
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
assertDocumentMoveOrderPlanMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "../../../convex/_utils/documentMoveOrder";
describe("documentMoveOrder", () => {
it("按 Rust canonical move order plan 计算跨父移动和越界 clamp", () => {
const plan = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "target",
parent_id: null,
sort_order: 0,
created_at: "2026-04-25T00:00:00Z",
},
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
{
id: "doc_c",
parent_id: "target",
sort_order: 0,
created_at: "2026-04-25T00:00:03Z",
},
],
documentId: "doc_b",
parentId: "target",
sortOrder: -2,
});
expect(plan).toEqual({
documentId: "doc_b",
fromParentId: "source",
toParentId: "target",
requestedSortOrder: -2,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: "target",
sortOrder: 0,
moved: true,
},
{
documentId: "doc_c",
parentId: "target",
sortOrder: 1,
moved: false,
},
],
});
});
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
{
id: "doc_c",
parent_id: "target",
sort_order: 0,
created_at: "2026-04-25T00:00:03Z",
},
],
documentId: "doc_b",
parentId: "target",
sortOrder: 0,
});
expect(() =>
assertDocumentMoveOrderPlanMatches(
{
...actual,
patches: actual.patches.map((patch) =>
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
),
},
actual,
),
).toThrow("Rust move plan 与 Convex 当前排序状态不一致");
});
});
@@ -1,16 +1,16 @@
import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export type MediaAssetReplaceStoragePayload = {
assetId: string;
@@ -32,21 +32,21 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
context: BridgeContext;
envelope: CommandEnvelope<MediaAssetReplaceStoragePayload>;
}): Promise<MediaAssetWritebackExecutionResult> {
const mutationRequest = buildDocumentBridgeMutationRequest({
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
mapConvexArgs: (payload) => ({
userId: payload.userId,
id: payload.assetId,
storageId: payload.storageId as Id<"_storage">,
}),
});
try {
await executeDocumentBridgeMutationRequest({
const transportResult = await executeRustBridgeMutationTransport({
client: input.client,
mutation: api.mediaAssets.replaceStorageFromUpload,
request: mutationRequest,
plan,
});
await recordRustBridgeCommandArtifacts({
client: input.client,
context: input.context,
envelope: input.envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -58,17 +58,6 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
throw error;
}
try {
await recordBridgeCommandArtifacts({
client: input.client,
context: input.context,
envelope: input.envelope,
});
} catch (error) {
// 说明:OnlyOffice callback 写回的主事实是附件存储替换;日志落账失败不应反向导致保存失败。
console.warn("[onlyoffice/callback] bridge log write skipped:", error);
}
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
@@ -1,14 +1,11 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
@@ -47,111 +44,27 @@ export type MetadataCommandExecutionResult = {
commandName: string;
};
type MetadataMutationArgs = Record<string, unknown>;
type MetadataWriteAdapter<TPayload> = {
convexMutation: unknown;
mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
};
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
"documents.title.update": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"page.head.updateTitle": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"documents.stats.update": {
convexMutation: api.documents.updateStats,
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
id: payload.documentId,
wordCount: payload.stats.wordCount,
characterCount: payload.stats.characterCount,
blockCount: payload.stats.blockCount,
todoTotal: payload.stats.todoTotal,
todoDone: payload.stats.todoDone,
}),
},
"documents.options.update": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.layout.updateOptions": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
};
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
const adapter = metadataWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面元信息命令适配器: ${commandName}`);
}
return adapter as MetadataWriteAdapter<TPayload>;
}
export async function executeMetadataBridgeCommand<TPayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<MetadataCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
try {
if (
input.envelope.name === "documents.title.update" ||
input.envelope.name === "page.head.updateTitle"
) {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
await executeRustBridgeMutationTransport({
client,
plan,
});
} else {
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs,
});
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation,
request: mutationRequest,
});
}
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
@@ -161,10 +74,6 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
});
throw error;
}
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
@@ -12,8 +12,8 @@ import {
type CommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
@@ -147,10 +147,12 @@ export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
client,
plan,
result,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -43,6 +43,7 @@ vi.mock("@/lib/documents/bridge", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
@@ -12,8 +12,8 @@ import {
type CommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
@@ -257,9 +257,20 @@ async function handleLifecycleError(error: unknown) {
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
let failureContext: BridgeContext | null = null;
let failureEnvelope: CommandEnvelope<{
documentId: string;
workspaceId: string;
parentId: string | null;
title: string;
accessScope: "private" | "shared" | "public";
content: unknown[];
}> | null = null;
try {
const payload = (await request.json()) as CreatePayload;
const { auth, client } = await getAuthedConvexClient();
failureClient = client;
const parentId = trimOrNull(payload.parentId);
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
@@ -304,6 +315,8 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
pageId: documentId,
},
});
failureContext = context;
failureEnvelope = envelope;
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId,
@@ -331,38 +344,22 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
}
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: created,
});
return NextResponse.json(created);
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as MovePayload;
const documentId = trimOrNull(payload.documentId);
if (documentId) {
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId,
parentId: trimOrNull(payload.parentId),
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
},
context,
target: {
workspaceId: sourceDoc?.workspace_id ?? null,
pageId: documentId,
},
});
if (failureClient && failureContext && failureEnvelope) {
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
client: failureClient,
context: failureContext,
envelope: failureEnvelope,
error,
});
}
@@ -427,14 +424,16 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: transportResult,
});
return NextResponse.json({ ok: true });
} catch (error) {
@@ -529,14 +528,16 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: transportResult,
});
return NextResponse.json({ success: true });
} catch (error) {
@@ -548,7 +549,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.restore",
name: "documents.delete",
payload: { documentId },
context,
target: {
@@ -600,14 +601,16 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: transportResult,
});
return NextResponse.json({ success: true });
} catch (error) {
@@ -620,12 +623,8 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
const workspaceId = sourceDoc?.workspace_id ?? null;
const context = await buildBridgeContext(request, workspaceId, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.duplicate",
payload: {
sourceDocumentId: documentId,
newDocumentId: "duplicate_failed",
title: sourceDoc?.title ?? null,
},
name: "documents.restore",
payload: { documentId },
context,
target: {
workspaceId,
@@ -689,8 +688,10 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean;
workspace_id: string;
access_scope: "private" | "shared" | "public";
is_template: boolean;
created_at: string;
updated_at: string;
}>({
@@ -701,10 +702,12 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await copyMindmapIfExists(documentId, duplicated.id);
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: duplicated,
});
return NextResponse.json({
@@ -715,26 +718,32 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
});
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
const { auth, client } = await getAuthedConvexClient();
const context = await buildBridgeContext(request, null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
items: (payload.items ?? []).map((item) => ({
documentId: trimOrNull(item.documentId) ?? "unknown",
recursive: Boolean(item.recursive),
})),
targetParentId: trimOrNull(payload.targetParentId),
},
context,
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
const documentId = trimOrNull(payload.documentId);
if (documentId) {
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const workspaceId = sourceDoc?.workspace_id ?? null;
const context = await buildBridgeContext(request, workspaceId, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.duplicate",
payload: {
sourceDocumentId: documentId,
newDocumentId: "duplicate_failed",
title: sourceDoc?.title ?? null,
},
context,
target: {
workspaceId,
},
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
}
} catch {
// 忽略日志写失败,保留原始错误返回
}
@@ -809,10 +818,12 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
await copyMindmapIfExists(item.oldId, item.newId);
}
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope: outerEnvelope,
client,
plan,
result,
});
return NextResponse.json({
@@ -37,6 +37,7 @@ vi.mock("@/lib/convex/route", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
@@ -69,7 +70,7 @@ const mockContext: BridgeContext = {
describe("page-write-command-adapter", () => {
it("标题命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -120,23 +121,18 @@ describe("page-write-command-adapter", () => {
name: "page.head.updateTitle",
}),
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "page.head.updateTitle",
}),
commandPayload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
streamDelta: {
op: "upsert_document",
document: {
id: "doc_1",
title: "新标题",
updated_at: "2026-04-24T00:00:00.000Z",
},
},
plan: expect.objectContaining({
commandName: "page.head.updateTitle",
}),
result: {
ok: true,
updated_at: "2026-04-24T00:00:00.000Z",
},
});
expect(result.commandName).toBe("page.head.updateTitle");
@@ -144,12 +140,41 @@ describe("page-write-command-adapter", () => {
expect(result.conflictDetectionKey).toBeNull();
});
it("页面设置命令应走 bridge mutation request", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
it("页面设置命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation } as unknown as ConvexHttpClient,
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.layout.updateOptions",
commandId: "cmd_options_1",
functionName: "documents:updateOptions",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
workspaceId: "ws_1",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const result = await executePageWriteBridgeCommand({
@@ -170,7 +195,27 @@ describe("page-write-command-adapter", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "page.layout.updateOptions",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "page.layout.updateOptions",
}),
plan: expect.objectContaining({
commandName: "page.layout.updateOptions",
functionName: "documents:updateOptions",
}),
result: {
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
},
});
expect(result.commandName).toBe("page.layout.updateOptions");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
@@ -1,15 +1,12 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type BridgeContext,
type CommandEnvelope,
DocumentBridgeError,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
@@ -23,14 +20,6 @@ type PageWritePayload =
| DocumentOptionsUpdatePayload
| DocumentSavePayload;
type MetadataMutationArgs = Record<string, unknown>;
type PageWriteAdapter<TPayload> = {
kind: "rust_transport" | "convex_mutation";
convexMutation?: unknown;
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
};
export type PageWriteCommandExecutionResult = {
requestId: string;
traceId: string;
@@ -40,70 +29,6 @@ export type PageWriteCommandExecutionResult = {
conflictDetectionKey: string | null;
};
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,
};
}
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
"page.head.updateTitle": {
kind: "rust_transport",
},
"page.layout.updateOptions": {
kind: "convex_mutation",
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.body.save": {
kind: "rust_transport",
},
};
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
const adapter = pageWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面写命令适配器: ${commandName}`);
}
return adapter as PageWriteAdapter<TPayload>;
}
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
return {
@@ -118,86 +43,29 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
};
}
function normalizeUpdatedAt(result: unknown): string | null {
const record = isRecord(result) ? result : null;
const updatedAt = record?.updated_at;
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
}
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
envelope: CommandEnvelope<TPayload>;
transportResult?: unknown;
}) {
if (input.envelope.name !== "page.head.updateTitle") {
return input.envelope.payload;
}
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
const updatedAt = normalizeUpdatedAt(input.transportResult);
return attachStreamDelta(payload, {
op: "upsert_document",
document: {
id: payload.documentId,
title: payload.title,
...(updatedAt ? { updated_at: updatedAt } : {}),
},
});
}
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<PageWriteCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
try {
if (adapter.kind === "rust_transport") {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
commandPayload: buildPageWriteCommandPayload({
envelope: input.envelope,
transportResult,
}),
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
}
const mutationRequest = buildDocumentBridgeMutationRequest({
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs!,
});
await executeDocumentBridgeMutationRequest({
const transportResult = await executeRustBridgeMutationTransport({
client,
mutation: adapter.convexMutation!,
request: mutationRequest,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
plan,
result: transportResult,
});
return {
@@ -205,8 +73,8 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: null,
conflictDetectionKey: null,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -1,4 +1,43 @@
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
import {
buildRustBridgeCommandArtifactPlan,
executeRustBridgeMutationTransport,
materializeRustTreeStreamDelta,
readRustTreeDomainEventPlan,
readRustTreeDomainEventType,
type RustBridgeCommandPlan,
} from "./rust-runtime";
vi.mock("@/lib/convex/api", () => ({
api: {
documents: {
move: "documents.move",
copyTree: "documents.copyTree",
updateStats: "documents.updateStats",
},
mediaAssets: {
batchCopy: "mediaAssets.batchCopy",
batchMove: "mediaAssets.batchMove",
},
},
}));
vi.mock("@/lib/documents/bridge", () => ({
DocumentBridgeError: class DocumentBridgeError extends Error {
status: number;
code: string;
details?: unknown;
constructor(message: string, status: number, code: string, details?: unknown) {
super(message);
this.name = "DocumentBridgeError";
this.status = status;
this.code = code;
this.details = details;
}
},
}));
let runtimeSelection: Record<string, unknown> = {};
@@ -41,3 +80,609 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
).toBe(true);
});
});
describe("executeRustBridgeMutationTransport", () => {
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
const normalizedMove = {
documentId: "doc_b",
fromParentId: "source",
toParentId: "target",
requestedSortOrder: 0,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: "target",
sortOrder: 0,
moved: true,
},
],
};
const mutation = vi.fn().mockResolvedValue({ ok: true });
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_move",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
id: "doc_b",
parentId: "target",
sortOrder: 0,
normalizedMove,
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toMatchObject({
id: "doc_b",
parentId: "target",
sortOrder: 0,
normalizedMove,
});
});
it("documents.copyTree 应注册为 Rust tree.subtree.copy 的 transport", async () => {
const mutation = vi.fn().mockResolvedValue({ items: [] });
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.copy",
commandId: "cmd_copy",
functionName: "documents:copyTree",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
targetParentId: "parent_1",
items: [
{
documentId: "doc_1",
recursive: true,
},
],
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).toHaveBeenCalledWith("documents.copyTree", {
targetParentId: "parent_1",
items: [
{
documentId: "doc_1",
recursive: true,
},
],
});
});
it("documents.updateStats 应注册为 Rust metadata transport", async () => {
const mutation = vi.fn().mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "documents.stats.update",
commandId: "cmd_stats",
functionName: "documents:updateStats",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
id: "doc_1",
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).toHaveBeenCalledWith("documents.updateStats", {
id: "doc_1",
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
});
});
it("tree.resource.copy/move 应把 Rust resourceTransferPlan 透传给媒体 transport", async () => {
const mutation = vi.fn().mockResolvedValue({ items: [] });
const basePlan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.resource.copy",
commandId: "cmd_asset_copy",
functionName: "mediaAssets:batchCopy",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: basePlan,
});
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: {
...basePlan,
commandName: "tree.resource.move",
commandId: "cmd_asset_move",
functionName: "mediaAssets:batchMove",
argsJson: {
...basePlan.argsJson,
resourceTransferPlan: {
action: "move",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
},
});
expect(mutation).toHaveBeenNthCalledWith(1, "mediaAssets.batchCopy", {
userId: "user_1",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
});
expect(mutation).toHaveBeenNthCalledWith(2, "mediaAssets.batchMove", {
userId: "user_1",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
resourceTransferPlan: {
action: "move",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
});
});
});
describe("materializeRustTreeStreamDelta", () => {
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_move",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_requested",
sortOrder: 9,
},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: {
parent_id: "parent_actual",
sort_order: 2,
updated_at: "2026-04-26T00:00:00Z",
},
}),
).toEqual({
op: "move_document",
documentId: "doc_1",
parentId: "parent_actual",
sortOrder: 2,
updatedAt: "2026-04-26T00:00:00Z",
});
});
it("应按 Rust copy_result hint 从结果 items 中生成 upsert_documents delta", () => {
const document = {
id: "copy_1",
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-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.copy",
commandId: "cmd_copy",
functionName: "documents:copyTree",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "copy_result",
args: {
itemsField: "items",
documentField: "document",
},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: {
items: [
{
oldId: "doc_1",
newId: "copy_1",
document,
},
],
},
}),
).toEqual({
op: "upsert_documents",
upsertDocuments: [document],
});
});
it("应按 Rust result_document hint 从根结果生成 upsert_document delta", () => {
const document = {
id: "copy_2",
workspace_id: "ws_1",
title: "复制页面 2",
parent_id: "parent_1",
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "documents.duplicate",
commandId: "cmd_duplicate",
functionName: "documents:duplicateWithMindmaps",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "result_document",
args: {},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: document,
}),
).toEqual({
op: "upsert_document",
document,
});
});
it("应按 Rust asset_result hint 从结果 items 中生成 upsert_assets delta", () => {
const asset = {
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",
};
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.resource.move",
commandId: "cmd_asset_move",
functionName: "mediaAssets:batchMove",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "asset_result",
args: {
itemsField: "items",
},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: {
items: [asset],
},
}),
).toEqual({
op: "upsert_assets",
upsertAssets: [asset],
});
});
});
describe("readRustTreeDomainEventType", () => {
it("应从 Rust domainEventHint 读取正式树域 event type", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.node.archive",
commandId: "cmd_archive",
functionName: "documents:softDelete",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
domainEventHint: {
family: "tree",
eventType: "tree.node.archived",
},
},
};
expect(readRustTreeDomainEventType(plan)).toBe("tree.node.archived");
});
it("应优先读取 Rust domainEventPlan 作为正式树域事件计划", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_move",
functionName: "documents:move",
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.subtree.moved",
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
},
},
domainEventHint: {
family: "tree",
eventType: "legacy.should_not_win",
},
},
};
expect(readRustTreeDomainEventType(plan)).toBe("tree.subtree.moved");
expect(readRustTreeDomainEventPlan(plan)).toEqual({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
},
});
});
});
describe("buildRustBridgeCommandArtifactPlan", () => {
it("应通过 Rust runtime commandArtifact 输入生成 artifact plan", async () => {
const artifactPlan = await buildRustBridgeCommandArtifactPlan({
context: {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_artifact_1",
traceId: "trace_artifact_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
},
envelope: {
name: "tree.subtree.move",
commandId: "cmd_artifact_1",
idempotencyKey: "idem_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "vitest",
},
target: {
workspaceId: "ws_1",
pageId: "doc_1",
},
payload: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
preflightData: null,
reason: "test",
refs: ["test"],
dryRun: false,
validateOnly: false,
},
plan: {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_artifact_1",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_artifact_1",
traceId: "trace_artifact_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
},
},
},
},
result: {
parent_id: "parent_1",
sort_order: 2,
updated_at: "2026-04-26T10:00:00Z",
},
now: "2026-04-26T10:00:01Z",
});
expect(artifactPlan?.commandLog).toMatchObject({
id: "clog_cmd_artifact_1",
workspaceId: "ws_1",
commandName: "tree.subtree.move",
payload: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
streamDelta: {
op: "move_document",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-26T10:00:00Z",
},
},
});
expect(artifactPlan?.domainEvent).toMatchObject({
id: "evt_cmd_artifact_1",
eventType: "tree.subtree.moved",
payload: {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
streamDelta: {
op: "move_document",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-26T10:00:00Z",
},
},
});
}, 30_000);
});
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api";
import {
DocumentBridgeError,
@@ -37,6 +38,10 @@ type RustRuntimeResponse =
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
}
| RustRuntimeExecutedQuery
| {
ok: true;
artifacts: RustBridgeCommandArtifactPlan | null;
}
| {
ok: false;
error: RustRuntimeErrorPayload;
@@ -68,6 +73,88 @@ export type RustBridgeCommandPlan = {
argsJson: Record<string, unknown>;
};
export type RustTreeDomainEventPlan = {
family: "tree";
schema: "mnote.tree.domain_event";
schemaVersion: 1;
eventType: string;
streamDeltaHint?: Record<string, unknown>;
streamDelta?: RustTreeStreamDelta;
};
export type RustTreeStreamDelta =
| {
op: "noop";
}
| {
op: "upsert_document";
document: Record<string, unknown>;
}
| {
op: "upsert_documents";
upsertDocuments: Record<string, unknown>[];
}
| {
op: "remove_document";
documentId: string;
}
| {
op: "move_document";
documentId: string;
parentId: string | null;
sortOrder: number;
updatedAt?: string;
}
| {
op: "upsert_assets";
upsertAssets: Record<string, unknown>[];
};
export type RustBridgeCommandLogArtifactPlan = {
workspaceId: string;
id: string;
requestId: string;
traceId: string;
commandId: string;
commandName: string;
actorId: string;
actorType: string;
sourceChannel: string;
sourceClient: string;
status: string;
targetPageId: string | null;
targetBlockId: string | null;
payload: unknown;
payloadSummary: string;
refs: string[];
idempotencyKey: string | null;
error: string | null;
createdAt: string;
finishedAt: string | null;
};
export type RustBridgeDomainEventArtifactPlan = {
workspaceId: string;
id: string;
requestId: string;
traceId: string;
commandId: string;
commandLogId: string;
eventType: string;
aggregateType: string;
aggregateId: string;
eventVersion: number;
status: string;
actorType: string;
payload: unknown;
createdAt: string;
};
export type RustBridgeCommandArtifactPlan = {
commandLog: RustBridgeCommandLogArtifactPlan;
domainEvent: RustBridgeDomainEventArtifactPlan | null;
};
export type RustBridgeToolPlanStep = {
kind: string;
name: string;
@@ -433,6 +520,371 @@ function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string)
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
function readStringArrayArg(argsJson: Record<string, unknown>, field: string): string[] {
const value = argsJson[field];
if (!Array.isArray(value)) {
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter((item) => item.length > 0);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readRecordField(source: unknown, field: string) {
if (!isRecord(source)) {
return null;
}
const value = source[field];
return isRecord(value) ? value : null;
}
function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
return isRecord(value) ? value : null;
}
function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
const value = source[field];
return typeof value === "boolean" ? value : undefined;
}
function readOptionalNonEmptyStringField(source: Record<string, unknown>, field: string) {
const value = source[field];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readNullableStringField(source: Record<string, unknown>, field: string) {
const value = source[field];
if (value === null) {
return null;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readTrimmedStringField(source: unknown, field: string) {
if (!isRecord(source)) {
return null;
}
const value = source[field];
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed || null;
}
function readOptionalParentIdFromResult(result: unknown, fallback: unknown): string | null {
if (isRecord(result) && "parent_id" in result) {
return readTrimmedStringField(result, "parent_id");
}
if (typeof fallback === "string") {
const trimmed = fallback.trim();
return trimmed || null;
}
return null;
}
function readFiniteNumberField(source: unknown, field: string) {
if (!isRecord(source)) {
return null;
}
const value = source[field];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function isTreeDeltaDocument(value: unknown): value is Record<string, unknown> {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.workspace_id === "string" &&
typeof value.access_scope === "string" &&
typeof value.is_template === "boolean" &&
typeof value.created_at === "string" &&
"title" in value &&
"parent_id" in value &&
"sort_order" in value &&
"is_starred" in value &&
"updated_at" in value
);
}
function isTreeDeltaAsset(value: unknown): value is Record<string, unknown> {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.workspace_id === "string" &&
typeof value.document_id === "string" &&
typeof value.asset_type === "string" &&
typeof value.created_at === "string" &&
typeof value.updated_at === "string" &&
"file_name" in value &&
"file_url" in value &&
"thumbnail_url" in value &&
"file_size" in value &&
"mime_type" in value
);
}
function readStreamDeltaHint(plan: RustBridgeCommandPlan) {
const hint = plan.argsJson.streamDeltaHint;
if (!isRecord(hint) || hint.family !== "tree" || typeof hint.kind !== "string") {
return null;
}
return {
kind: hint.kind,
args: isRecord(hint.args) ? hint.args : {},
};
}
export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string | null {
const eventPlan = readRustTreeDomainEventPlan(plan);
if (eventPlan) {
return eventPlan.eventType;
}
const hint = plan.argsJson.domainEventHint;
if (!isRecord(hint) || hint.family !== "tree") {
return null;
}
return readTrimmedStringField(hint, "eventType");
}
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
const eventPlan = plan.argsJson.domainEventPlan;
if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
return null;
}
if (eventPlan.schema !== "mnote.tree.domain_event" || eventPlan.schemaVersion !== 1) {
return null;
}
const eventType = readTrimmedStringField(eventPlan, "eventType");
if (!eventType) {
return null;
}
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
return {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType,
...(streamDeltaHint ? { streamDeltaHint } : {}),
...(streamDelta ? { streamDelta } : {}),
};
}
export function materializeRustTreeDomainEventPlan(input: {
plan: RustBridgeCommandPlan;
result: unknown;
streamDelta?: RustTreeStreamDelta | null;
}): RustTreeDomainEventPlan | null {
const eventPlan = readRustTreeDomainEventPlan(input.plan);
if (!eventPlan) {
return null;
}
const streamDelta =
input.streamDelta ??
materializeRustTreeStreamDelta({
plan: input.plan,
result: input.result,
});
return {
...eventPlan,
...(streamDelta ? { streamDelta } : {}),
};
}
export function materializeRustTreeStreamDelta(input: {
plan: RustBridgeCommandPlan;
result: unknown;
}): RustTreeStreamDelta | null {
const hint = readStreamDeltaHint(input.plan);
if (!hint) {
return null;
}
if (hint.kind === "noop") {
return { op: "noop" };
}
if (hint.kind === "remove_document") {
const documentId = readTrimmedStringField(hint.args, "documentId");
return documentId ? { op: "remove_document", documentId } : null;
}
if (hint.kind === "upsert_document_patch") {
const documentId = readTrimmedStringField(hint.args, "documentId");
const patch = readRecordField(hint.args, "patch");
if (!documentId || !patch) {
return null;
}
const updatedAt = readTrimmedStringField(input.result, "updated_at");
return {
op: "upsert_document",
document: {
id: documentId,
...patch,
...(updatedAt && !("updated_at" in patch) ? { updated_at: updatedAt } : {}),
},
};
}
if (hint.kind === "document_result") {
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
const document = readRecordField(input.result, documentField);
if (!isTreeDeltaDocument(document)) {
return null;
}
return {
op: "upsert_document",
document,
};
}
if (hint.kind === "result_document") {
if (!isTreeDeltaDocument(input.result)) {
return null;
}
return {
op: "upsert_document",
document: input.result,
};
}
if (hint.kind === "copy_result") {
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
const upsertDocuments = items
.map((item) => readRecordField(item, documentField))
.filter(isTreeDeltaDocument);
return upsertDocuments.length > 0
? {
op: "upsert_documents",
upsertDocuments,
}
: null;
}
if (hint.kind === "asset_result") {
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
const upsertAssets = items.filter(isTreeDeltaAsset);
return upsertAssets.length > 0
? {
op: "upsert_assets",
upsertAssets,
}
: null;
}
if (hint.kind === "move_document") {
const documentId = readTrimmedStringField(hint.args, "documentId");
const fallbackSortOrder = readFiniteNumberField(hint.args, "sortOrder");
const sortOrder = readFiniteNumberField(input.result, "sort_order") ?? fallbackSortOrder;
if (!documentId || typeof sortOrder !== "number") {
return null;
}
const updatedAt = readTrimmedStringField(input.result, "updated_at");
return {
op: "move_document",
documentId,
parentId: readOptionalParentIdFromResult(input.result, hint.args.parentId),
sortOrder,
...(updatedAt ? { updatedAt } : {}),
};
}
return null;
}
function nowIsoForRustArtifact() {
return new Date().toISOString();
}
export async function buildRustBridgeCommandArtifactPlan(input: {
context: BridgeContext;
envelope: CommandEnvelope<unknown>;
plan: RustBridgeCommandPlan;
result: unknown;
now?: string;
}): Promise<RustBridgeCommandArtifactPlan | null> {
const response = await runRustRuntime({
kind: "commandArtifact",
context: input.context,
command: {
...input.envelope,
preflightData: input.envelope.preflightData ?? null,
},
plan: input.plan,
result: input.result,
now: input.now ?? nowIsoForRustArtifact(),
});
if (!("artifacts" in response)) {
throw new DocumentBridgeError("Rust runtime 未返回 command artifact plan", 500, "TRANSPORT_ERROR");
}
return response.artifacts;
}
export async function persistRustBridgeCommandArtifacts(input: {
client: ConvexHttpClient;
artifacts: RustBridgeCommandArtifactPlan | null;
}): Promise<void> {
const artifacts = input.artifacts;
if (!artifacts) {
return;
}
const bridgeLogsApi = api as typeof api & {
bridgeLogs: {
recordCommandLog: unknown;
recordDomainEvent: unknown;
};
};
const mutation = input.client.mutation.bind(input.client) as (
mutationReference: unknown,
args: Record<string, unknown>,
) => Promise<unknown>;
await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>);
if (artifacts.domainEvent) {
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record<string, unknown>);
}
}
export async function recordRustBridgeCommandArtifacts(input: {
context: BridgeContext;
envelope: CommandEnvelope<unknown>;
client: ConvexHttpClient;
plan: RustBridgeCommandPlan;
result: unknown;
now?: string;
}): Promise<RustBridgeCommandArtifactPlan | null> {
const artifacts = await buildRustBridgeCommandArtifactPlan({
context: input.context,
envelope: input.envelope,
plan: input.plan,
result: input.result,
now: input.now,
});
await persistRustBridgeCommandArtifacts({
client: input.client,
artifacts,
});
return artifacts;
}
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
context: BridgeContext;
envelope: QueryEnvelope<TPayload>;
@@ -653,6 +1105,12 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
mutationReference: unknown,
args: Record<string, unknown>,
) => Promise<TResult>;
const runtimeApi = api as typeof api & {
mediaAssets: {
batchCopy: unknown;
batchMove: unknown;
};
};
switch (input.plan.functionName) {
case "documents:createWithParentReference":
@@ -669,6 +1127,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
id: assertStringArg(input.plan.argsJson, "id"),
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
...("normalizedMove" in input.plan.argsJson
? { normalizedMove: input.plan.argsJson.normalizedMove }
: {}),
});
case "documents:softDelete":
return mutation(api.documents.softDelete, {
@@ -684,6 +1145,77 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
newId: assertStringArg(input.plan.argsJson, "newId"),
title: readOptionalStringArg(input.plan.argsJson, "title"),
});
case "documents:updateStats":
return mutation(api.documents.updateStats, {
id: assertStringArg(input.plan.argsJson, "id"),
wordCount: readRequiredNumberArg(input.plan.argsJson, "wordCount"),
characterCount: readRequiredNumberArg(input.plan.argsJson, "characterCount"),
blockCount: readRequiredNumberArg(input.plan.argsJson, "blockCount"),
todoTotal: readRequiredNumberArg(input.plan.argsJson, "todoTotal"),
todoDone: readRequiredNumberArg(input.plan.argsJson, "todoDone"),
});
case "documents:updateOptions": {
const options = readOptionalRecordArg(input.plan.argsJson, "options");
if (!options) {
throw new DocumentBridgeError("Rust runtime 缺少 options", 500, "TRANSPORT_ERROR");
}
return mutation(api.documents.updateOptions, {
id: assertStringArg(input.plan.argsJson, "id"),
options: {
wideLayout: readOptionalBooleanField(options, "wideLayout"),
smallText: readOptionalBooleanField(options, "smallText"),
showHeadingNumbers: readOptionalBooleanField(options, "showHeadingNumbers"),
showToc: readOptionalBooleanField(options, "showToc"),
showStructure: readOptionalBooleanField(options, "showStructure"),
protectEditing: readOptionalBooleanField(options, "protectEditing"),
showWordCount: readOptionalBooleanField(options, "showWordCount"),
collapseBacklinks: readOptionalBooleanField(options, "collapseBacklinks"),
pageFont: readOptionalNonEmptyStringField(options, "pageFont"),
layoutDensity: readOptionalNonEmptyStringField(options, "layoutDensity"),
hideChildPages: readOptionalBooleanField(options, "hideChildPages"),
showBlockRefCount: readOptionalBooleanField(options, "showBlockRefCount"),
embedDefaultBlockId: readNullableStringField(options, "embedDefaultBlockId"),
},
});
}
case "documents:copyTree": {
const rawItems = input.plan.argsJson.items;
const items = Array.isArray(rawItems)
? rawItems
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
.map((item) => ({
documentId: assertStringArg(item, "documentId"),
recursive: Boolean(item.recursive),
}))
: [];
return mutation(api.documents.copyTree, {
items,
targetParentId: readOptionalStringArg(input.plan.argsJson, "targetParentId"),
});
}
case "mediaAssets:batchCopy":
case "mediaAssets:batchMove":
return mutation(
input.plan.functionName === "mediaAssets:batchCopy"
? runtimeApi.mediaAssets.batchCopy
: runtimeApi.mediaAssets.batchMove,
{
userId: input.plan.actorId,
assetIds: readStringArrayArg(input.plan.argsJson, "assetIds"),
targetDocumentId: assertStringArg(input.plan.argsJson, "targetDocumentId"),
targetSubPath: readOptionalStringArg(input.plan.argsJson, "targetSubPath"),
resourceTransferPlan: readOptionalRecordArg(
input.plan.argsJson,
"resourceTransferPlan",
),
},
);
case "mediaAssets:replaceStorageFromUpload":
return mutation(api.mediaAssets.replaceStorageFromUpload, {
userId: assertStringArg(input.plan.argsJson, "userId"),
id: assertStringArg(input.plan.argsJson, "id"),
storageId: assertStringArg(input.plan.argsJson, "storageId") as Id<"_storage">,
});
case "documents:setTemplate":
return mutation(api.documents.setTemplate, {
id: assertStringArg(input.plan.argsJson, "id"),
@@ -4,8 +4,8 @@ import {
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import { DocumentBridgeError } from "@/lib/documents/bridge";
@@ -58,9 +58,12 @@ export async function executeSaveBridgeCommand(input: {
}
throw error;
}
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
plan,
result: mutationResult,
});
return {
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchKernelFileTreeProjection } from "./projection-client";
describe("fetchKernelFileTreeProjection", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("通过 3000 同源 file_tree projection endpoint 请求 Rust 搜索 projection", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
result: {
projectionId: "kernel_projection:file_tree:page_root",
projection: "file_tree",
rootNodeId: "page_root",
items: [{ rowId: "asset:table_1" }],
edges: [],
},
}),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);
const result = await fetchKernelFileTreeProjection({
workspaceId: "ws_1",
rootNodeId: "page_root",
depth: 3,
query: " 预算 ",
maxResults: 12,
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
expect.objectContaining({
method: "GET",
credentials: "include",
}),
);
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
});
it("失败时透出服务端错误消息", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "获取 projection 失败" }), { status: 502 }),
),
);
await expect(fetchKernelFileTreeProjection({ workspaceId: "ws_1" })).rejects.toThrow(
"获取 projection 失败",
);
});
});
@@ -0,0 +1,48 @@
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
export type FetchKernelFileTreeProjectionInput = {
workspaceId: string;
rootNodeId?: string | null;
depth?: number | null;
query?: string | null;
maxResults?: number | null;
};
export async function fetchKernelFileTreeProjection(
input: FetchKernelFileTreeProjectionInput,
): Promise<KernelFileTreeProjection> {
const params = new URLSearchParams();
params.set("workspaceId", input.workspaceId);
const rootNodeId = input.rootNodeId?.trim();
if (rootNodeId) {
params.set("rootNodeId", rootNodeId);
}
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
params.set("depth", String(input.depth));
}
const query = input.query?.trim();
if (query) {
params.set("query", query);
}
if (typeof input.maxResults === "number" && Number.isFinite(input.maxResults)) {
params.set("maxResults", String(Math.max(1, Math.floor(input.maxResults))));
}
const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
method: "GET",
credentials: "include",
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message =
typeof payload?.error === "string" ? payload.error : "获取 file_tree projection 失败";
throw new Error(message);
}
const payload = (await response.json()) as { result?: KernelFileTreeProjection };
if (!payload.result) {
throw new Error("file_tree projection 响应缺少 result");
}
return payload.result;
}
@@ -0,0 +1,317 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyFileTreeResourceAssets,
deleteFileTreeResourceAssets,
preflightFileTreeDelete,
preflightFileTreeInternalDrop,
preflightFileTreePaste,
preflightFileTreeUploadTarget,
moveFileTreeResourceAssets,
renameFileTreeResourceAsset,
restoreFileTreeResourceAssets,
uploadFileTreeResourceAsset,
} from "./resource-command-client";
describe("file-tree resource command client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("copy/move 应通过统一资源 command client 发送到 media batch route", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ items: [{ id: "asset_1" }] }),
} as Response);
await copyFileTreeResourceAssets({
assetIds: ["asset_1", "asset_1", " asset_2 "],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
});
await moveFileTreeResourceAssets({
assetIds: ["asset_3"],
targetDocumentId: "doc_target_2",
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"/api/media/batch",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "copy",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"/api/media/batch",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "move",
assetIds: ["asset_3"],
targetDocumentId: "doc_target_2",
}),
}),
);
});
it("rename/delete/restore 也应复用同一 batch transport 边界", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true }),
} as Response);
await renameFileTreeResourceAsset({ assetId: "asset_1", newName: "新文件.pdf" });
await deleteFileTreeResourceAssets(["asset_1", "asset_2"]);
await restoreFileTreeResourceAssets(["asset_3"]);
expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([
{ action: "rename", assetIds: ["asset_1"], newName: "新文件.pdf" },
{ action: "delete", assetIds: ["asset_1", "asset_2"] },
{ action: "restore", assetIds: ["asset_3"] },
]);
});
it("后端返回错误时应抛出稳定 fallback 或服务端消息", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
json: async () => ({ error: "Rust resource preflight rejected" }),
} as Response);
await expect(
moveFileTreeResourceAssets({
assetIds: ["asset_1"],
targetDocumentId: "doc_target",
}),
).rejects.toThrow("Rust resource preflight rejected");
});
it("upload 应通过统一资源 command client 构造 FormData transport", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ asset: { id: "asset_upload_1" } }),
} as Response);
const file = new File(["content"], "demo.pdf", { type: "application/pdf" });
await uploadFileTreeResourceAsset({
file,
workspaceId: "ws_1",
documentId: "doc_1",
mindmapId: "mind_1",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/media/upload",
expect.objectContaining({
method: "POST",
body: expect.any(FormData),
}),
);
const body = fetchMock.mock.calls[0]?.[1]?.body as FormData;
expect(body.get("file")).toBe(file);
expect(body.get("workspaceId")).toBe("ws_1");
expect(body.get("documentId")).toBe("doc_1");
expect(body.get("mindmapId")).toBe("mind_1");
});
it("internal drop preflight 应发送到 tree filetree drop route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
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,
},
}),
} as Response);
const plan = await preflightFileTreeInternalDrop({
workspaceId: "ws_1",
copy: false,
targetDocumentId: "doc_target",
targetRowId: null,
focusedRowId: null,
activeDocumentId: null,
rowIds: ["doc:doc_1"],
rows: [],
documentParents: [],
});
expect(plan.topLevelDocIds).toEqual(["doc_1"]);
expect(plan.documentTransferPlan?.topLevelDocumentIds).toEqual(["doc_1"]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/drop-preflight",
expect.objectContaining({
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: [],
}),
}),
);
});
it("delete preflight 应发送到 tree filetree delete route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
plan: {
rowIds: ["doc:doc_1", "asset:asset_1"],
docIds: ["doc_1"],
assetIds: ["asset_1"],
assetDocumentIds: ["doc_other"],
},
}),
} as Response);
const plan = await preflightFileTreeDelete({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
rows: [],
documentParents: [],
});
expect(plan.docIds).toEqual(["doc_1"]);
expect(plan.assetIds).toEqual(["asset_1"]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/delete-preflight",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceId: "ws_1",
rowIds: ["doc:doc_1", "asset:asset_1"],
rows: [],
documentParents: [],
}),
}),
);
});
it("paste preflight 应发送到 tree filetree paste route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
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",
},
},
}),
} as Response);
const plan = await preflightFileTreePaste({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_1", "asset:asset_1"],
rows: [],
});
expect(plan.docItems).toEqual([{ documentId: "doc_1", recursive: false }]);
expect(plan.resourceTransferPlan?.targetSubPath).toBe("mindmaps/mind_1");
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/paste-preflight",
expect.objectContaining({
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: [],
}),
}),
);
});
it("upload target preflight 应发送到 tree filetree upload-target route 并返回 Rust plan", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
plan: {
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
},
}),
} as Response);
const plan = await preflightFileTreeUploadTarget({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: null,
activeDocumentId: "doc_active",
rows: [],
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
});
expect(plan).toEqual({
workspaceId: "ws_1",
targetDocumentId: "doc_target",
targetMindmapId: "mind_1",
targetSubPath: "mindmaps/mind_1",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/tree/filetree/upload-target-preflight",
expect.objectContaining({
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" }],
}),
}),
);
});
});
@@ -0,0 +1,315 @@
"use client";
import type { MediaAsset } from "@/types/media";
import type {
FileTreeShellDeletePreflightPayload,
FileTreeShellInternalDropPreflightPayload,
FileTreeShellPastePreflightPayload,
FileTreeShellUploadTargetPreflightPayload,
} from "@/lib/file-tree/shell";
type ResourceCommandAction = "copy" | "move" | "rename" | "delete" | "restore";
type ResourceCommandErrorPayload = {
error?: string;
};
type ResourceCommandResponse = {
ok?: boolean;
items?: MediaAsset[];
asset?: MediaAsset;
};
type TransferResourceAssetsInput = {
assetIds: readonly string[];
targetDocumentId: string;
targetSubPath?: string | null;
};
type RenameResourceAssetInput = {
assetId: string;
newName: string;
};
type UploadResourceAssetInput = {
file: File;
workspaceId: string;
documentId: string;
mindmapId?: string | null;
};
export type FileTreeInternalDropPreflightPlan = {
copy: boolean;
targetDocumentId: string;
targetMindmapId: string | null;
targetSubPath?: string | null;
rowIds: string[];
docIds: string[];
topLevelDocIds: string[];
copyableAssetIds: string[];
sourceAssetDocumentIds: string[];
documentTransferPlan?: {
action: "copy" | "move";
targetParentId: string;
documentIds: string[];
topLevelDocumentIds: string[];
copyItems: Array<{ documentId: string; recursive: boolean }>;
} | null;
resourceTransferPlan?: {
action: "copy" | "move";
assetIds: string[];
targetDocumentId: string;
targetSubPath?: string | null;
} | null;
};
type FileTreeInternalDropPreflightResponse = {
plan?: FileTreeInternalDropPreflightPlan;
};
export type FileTreeDeletePreflightPlan = {
rowIds: string[];
docIds: string[];
assetIds: string[];
assetDocumentIds: string[];
};
type FileTreeDeletePreflightResponse = {
plan?: FileTreeDeletePreflightPlan;
};
export type FileTreePastePreflightPlan = {
targetDocumentId: string;
targetMindmapId: string | null;
targetSubPath?: string | null;
rowIds: string[];
docItems: Array<{ documentId: string; recursive: boolean }>;
copyableAssetIds: string[];
resourceTransferPlan?: {
action: "copy";
assetIds: string[];
targetDocumentId: string;
targetSubPath?: string | null;
} | null;
};
type FileTreePastePreflightResponse = {
plan?: FileTreePastePreflightPlan;
};
export type FileTreeUploadTargetPreflightPlan = {
workspaceId: string;
targetDocumentId: string;
targetMindmapId: string | null;
targetSubPath?: string | null;
};
type FileTreeUploadTargetPreflightResponse = {
plan?: FileTreeUploadTargetPreflightPlan;
};
function normalizeAssetIds(assetIds: readonly string[]): string[] {
return Array.from(
new Set(
assetIds
.map((assetId) => (typeof assetId === "string" ? assetId.trim() : ""))
.filter(Boolean),
),
);
}
async function postResourceCommand<TResult>(
payload: Record<string, unknown>,
fallbackMessage: string,
path = "/api/media/batch",
): Promise<TResult> {
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| TResult
| ResourceCommandErrorPayload
| null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
async function postResourceForm<TResult>(
path: string,
formData: FormData,
fallbackMessage: string,
): Promise<TResult> {
const response = await fetch(path, {
method: "POST",
body: formData,
});
const body = (await response.json().catch(() => null)) as
| TResult
| ResourceCommandErrorPayload
| null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
function buildTransferPayload(
action: Extract<ResourceCommandAction, "copy" | "move">,
input: TransferResourceAssetsInput,
) {
const payload: Record<string, unknown> = {
action,
assetIds: normalizeAssetIds(input.assetIds),
targetDocumentId: input.targetDocumentId,
};
const targetSubPath = input.targetSubPath?.trim();
if (targetSubPath) {
payload.targetSubPath = targetSubPath;
}
return payload;
}
export async function copyFileTreeResourceAssets(
input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
buildTransferPayload("copy", input),
"复制附件失败",
);
}
export async function moveFileTreeResourceAssets(
input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
buildTransferPayload("move", input),
"移动附件失败",
);
}
export async function renameFileTreeResourceAsset(
input: RenameResourceAssetInput,
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
{
action: "rename",
assetIds: normalizeAssetIds([input.assetId]),
newName: input.newName,
},
"重命名失败",
);
}
export async function deleteFileTreeResourceAssets(
assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
{
action: "delete",
assetIds: normalizeAssetIds(assetIds),
},
"删除失败",
);
}
export async function restoreFileTreeResourceAssets(
assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
return postResourceCommand<ResourceCommandResponse>(
{
action: "restore",
assetIds: normalizeAssetIds(assetIds),
},
"恢复附件失败,请稍后再试",
);
}
export async function uploadFileTreeResourceAsset(
input: UploadResourceAssetInput,
): Promise<ResourceCommandResponse> {
const formData = new FormData();
formData.append("file", input.file);
formData.append("workspaceId", input.workspaceId);
formData.append("documentId", input.documentId);
const mindmapId = input.mindmapId?.trim();
if (mindmapId) {
formData.append("mindmapId", mindmapId);
}
return postResourceForm<ResourceCommandResponse>(
"/api/media/upload",
formData,
"上传失败",
);
}
export async function preflightFileTreeInternalDrop(
input: FileTreeShellInternalDropPreflightPayload,
): Promise<FileTreeInternalDropPreflightPlan> {
const response = await postResourceCommand<FileTreeInternalDropPreflightResponse>(
input,
"文件树拖放预检失败",
"/api/tree/filetree/drop-preflight",
);
if (!response.plan) {
throw new Error("文件树拖放预检失败");
}
return response.plan;
}
export async function preflightFileTreeDelete(
input: FileTreeShellDeletePreflightPayload,
): Promise<FileTreeDeletePreflightPlan> {
const response = await postResourceCommand<FileTreeDeletePreflightResponse>(
input,
"文件树删除预检失败",
"/api/tree/filetree/delete-preflight",
);
if (!response.plan) {
throw new Error("文件树删除预检失败");
}
return response.plan;
}
export async function preflightFileTreePaste(
input: FileTreeShellPastePreflightPayload,
): Promise<FileTreePastePreflightPlan> {
const response = await postResourceCommand<FileTreePastePreflightResponse>(
input,
"文件树粘贴预检失败",
"/api/tree/filetree/paste-preflight",
);
if (!response.plan) {
throw new Error("文件树粘贴预检失败");
}
return response.plan;
}
export async function preflightFileTreeUploadTarget(
input: FileTreeShellUploadTargetPreflightPayload,
): Promise<FileTreeUploadTargetPreflightPlan> {
const response = await postResourceCommand<FileTreeUploadTargetPreflightResponse>(
input,
"文件树上传目标预检失败",
"/api/tree/filetree/upload-target-preflight",
);
if (!response.plan) {
throw new Error("文件树上传目标预检失败");
}
return response.plan;
}
+14 -207
View File
@@ -1,10 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
import { buildVisibleRows } from "./rows";
import { parseFileTreeRowId } from "./types";
describe("buildVisibleRows", () => {
it("按展开状态稳定生成可见行", () => {
it("缺少 kernel file_tree items 时不再回退 pageRows + assets 重建对象语义", () => {
const a = {
access_scope: "private" as const,
id: "a",
@@ -34,7 +33,17 @@ describe("buildVisibleRows", () => {
};
const rows = buildVisibleRows({
pageRows: buildPageTreeProjectionItems([a]),
pageRows: [
{
nodeId: a.id,
parentNodeId: null,
depth: 0,
childCount: 1,
position: 0,
title: a.title,
node: a,
},
],
expanded: new Set(["a"]),
assetsByDoc: {
a: [
@@ -82,40 +91,7 @@ describe("buildVisibleRows", () => {
},
});
expect(rows.map((r) => `${r.kind}:${r.depth}:${r.rowId}`)).toEqual([
"doc:0:doc:a",
"index:1:index:a",
"asset:1:asset:x",
"asset:1:asset:y",
"doc:1:doc:b",
]);
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
});
it("输入树包含重复 docId 时应自动去重", () => {
const a = {
access_scope: "private" as const,
id: "a",
workspace_id: "w",
title: "A",
parent_id: null,
sort_order: 0,
is_starred: null,
is_template: false,
created_at: "",
updated_at: null,
children: [],
};
const rows = buildVisibleRows({
pageRows: buildPageTreeProjectionItems([a, a]),
expanded: new Set(["a"]),
assetsByDoc: {},
});
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
expect(rows).toEqual([]);
});
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
@@ -284,175 +260,6 @@ describe("buildVisibleRows", () => {
});
});
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
const fileTreeItems = [
{
rowId: "doc:page_root",
rowKind: "document",
nodeId: "page_root",
parentNodeId: null,
nodeType: "page",
projectionKind: "file_tree",
title: "根页面",
depth: 0,
position: 0,
childCount: 3,
expandable: true,
expandedByDefault: true,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:page_root",
rowKind: "index",
nodeId: "index:page_root",
parentNodeId: "page_root",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 1,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "page_root",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset_folder",
nodeId: "asset-folder:mind_1",
parentNodeId: "page_root",
nodeType: "mindmap",
projectionKind: "file_tree",
title: "头脑风暴",
depth: 1,
position: 1,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open-asset", "select"],
resourceMeta: {
resourceKind: "mindmap",
documentId: "page_root",
assetId: "mind_1",
workspaceId: "ws_1",
assetKind: "mindmap",
iconHint: "mindmap",
},
iconHint: "mindmap",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
nodeId: "asset:asset_child_1",
parentNodeId: "asset-folder:mind_1",
nodeType: "asset",
projectionKind: "file_tree",
title: "节点图片.png",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open-asset", "select"],
resourceMeta: {
resourceKind: "asset",
documentId: "page_root",
assetId: "asset_child_1",
workspaceId: "ws_1",
assetKind: "image",
iconHint: "image",
},
iconHint: "image",
},
{
rowId: "doc:page_child",
rowKind: "document",
nodeId: "page_child",
parentNodeId: "page_root",
nodeType: "page",
projectionKind: "file_tree",
title: "子页面",
depth: 1,
position: 2,
childCount: 1,
expandable: true,
expandedByDefault: false,
capabilities: ["expand", "open", "select"],
resourceMeta: {
resourceKind: "document",
documentId: "page_child",
workspaceId: "ws_1",
iconHint: "page",
},
iconHint: "page",
},
{
rowId: "index:page_child",
rowKind: "index",
nodeId: "index:page_child",
parentNodeId: "page_child",
nodeType: "index",
projectionKind: "file_tree",
title: "index.md",
depth: 2,
position: 0,
childCount: 0,
expandable: false,
expandedByDefault: false,
capabilities: ["open", "select"],
resourceMeta: {
resourceKind: "index",
documentId: "page_child",
workspaceId: "ws_1",
iconHint: "index",
},
iconHint: "index",
},
] as const;
const filteredItems = filterKernelFileTreeProjectionItems({
fileTreeItems: [...fileTreeItems],
visibleDocumentIds: new Set(["page_root", "page_child"]),
expandedDocumentIds: new Set(["page_root"]),
expandedAssetFolderIds: new Set(["mind_1"]),
});
expect(filteredItems.map((item) => item.rowId)).toEqual([
"doc:page_root",
"index:page_root",
"asset-folder:mind_1",
"asset:asset_child_1",
"doc:page_child",
]);
const rows = buildVisibleRows({
fileTreeItems: filteredItems,
expanded: new Set(["page_root"]),
expandedAssetFolderIds: new Set(["mind_1"]),
});
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
"doc:doc:page_root",
"index:index:page_root",
"asset-folder:asset-folder:mind_1",
"asset:asset:asset_child_1",
"doc:doc:page_child",
]);
});
});
describe("parseFileTreeRowId", () => {
+2 -111
View File
@@ -12,40 +12,6 @@ import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
export function filterKernelFileTreeProjectionItems(input: {
fileTreeItems: KernelFileTreeProjectionItem[];
visibleDocumentIds: ReadonlySet<string>;
expandedDocumentIds: ReadonlySet<string>;
expandedAssetFolderIds?: ReadonlySet<string>;
}): KernelFileTreeProjectionItem[] {
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
return input.fileTreeItems.filter((item) => {
const docId = getDocIdFromFileTreeItem(item);
if (!input.visibleDocumentIds.has(docId)) {
return false;
}
switch (item.rowKind) {
case "document":
return true;
case "index":
case "asset_folder":
return input.expandedDocumentIds.has(docId);
case "asset": {
if (!input.expandedDocumentIds.has(docId)) {
return false;
}
const parentNodeId = String(item.parentNodeId ?? "").trim();
if (parentNodeId.startsWith("asset-folder:")) {
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
}
return true;
}
}
});
}
function buildRowsFromKernelFileTreeProjection(input: {
fileTreeItems: KernelFileTreeProjectionItem[];
expanded: Set<string>;
@@ -124,16 +90,13 @@ function buildRowsFromKernelFileTreeProjection(input: {
export function buildVisibleRows({
fileTreeItems,
pageRows,
expanded,
assetsByDoc,
assetChildrenByAssetId,
expandedAssetFolderIds,
nodeById,
assetById,
}: {
fileTreeItems?: KernelFileTreeProjectionItem[];
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
// 兼容旧调用签名;主路径必须提供 kernel file_tree items,不能再从这些字段重建对象语义。
pageRows?: PageTreeProjectionItem[];
expanded: Set<string>;
assetsByDoc?: Record<string, MediaAsset[]>;
@@ -152,77 +115,5 @@ export function buildVisibleRows({
});
}
const safePageRows = pageRows ?? [];
const safeAssetsByDoc = assetsByDoc ?? {};
const rows: FileTreeRow[] = [];
safePageRows.forEach((item) => {
const assets = safeAssetsByDoc[item.nodeId] ?? [];
const hasChildren = item.childCount > 0 || assets.length > 0;
const isExpanded = expanded.has(item.nodeId);
rows.push({
kind: "doc",
rowId: makeDocRowId(item.nodeId),
depth: item.depth,
docId: item.nodeId,
parentDocId: item.parentNodeId,
node: item.node,
hasChildren,
isExpanded,
});
if (!isExpanded) {
return;
}
rows.push({
kind: "index",
rowId: makeIndexRowId(item.nodeId),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
node: item.node,
});
assets.forEach((asset) => {
if (asset.asset_type === "mindmap") {
const children = assetChildrenByAssetId?.[asset.id] ?? [];
const hasChildren = children.length > 0;
const isExpanded = expandedAssetFolderIds?.has(asset.id) ?? false;
rows.push({
kind: "asset-folder",
rowId: makeAssetFolderRowId(asset.id),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
asset,
hasChildren,
isExpanded,
});
if (hasChildren && isExpanded) {
children.forEach((child) => {
rows.push({
kind: "asset",
rowId: makeAssetRowId(child.id),
depth: item.depth + 2,
docId: item.nodeId,
parentDocId: item.nodeId,
asset: child,
});
});
}
return;
}
rows.push({
kind: "asset",
rowId: makeAssetRowId(asset.id),
depth: item.depth + 1,
docId: item.nodeId,
parentDocId: item.nodeId,
asset,
});
});
});
return rows;
return [];
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { FileTreeSelectionState } from "./selection";
import {
createEmptyFileTreeSelectionState,
materializeRendererSelectionSnapshot,
resolveActiveFileTreeSelection,
} from "./selection-source";
function selection(
selectedRowIds: string[],
anchorRowId: string | null,
focusedRowId: string | null,
): FileTreeSelectionState {
return {
selectedRowIds: new Set(selectedRowIds),
anchorRowId,
focusedRowId,
};
}
describe("selection-source", () => {
it("rust_family 应优先消费 renderer selection snapshot", () => {
const legacySelection = selection(["legacy"], "legacy", "legacy");
const rendererSelection = selection(["renderer"], "renderer", "renderer");
expect(
resolveActiveFileTreeSelection({
preferRendererSnapshot: true,
legacySelection,
rendererSelection,
}),
).toBe(rendererSelection);
expect(
resolveActiveFileTreeSelection({
preferRendererSnapshot: false,
legacySelection,
rendererSelection,
}),
).toBe(legacySelection);
});
it("renderer event snapshot 只过滤未知 row,不在宿主侧重算 focus/anchor", () => {
const snapshot = materializeRendererSelectionSnapshot({
payload: {
selectedRowIds: ["doc:a", "missing"],
anchorRowId: "missing",
focusedRowId: "doc:a",
},
hasRowId: (rowId) => rowId === "doc:a",
});
expect(Array.from(snapshot.selectedRowIds)).toEqual(["doc:a"]);
expect(snapshot.anchorRowId).toBeNull();
expect(snapshot.focusedRowId).toBe("doc:a");
});
it("空 selection 工厂应返回互不共享的 Set 实例", () => {
const a = createEmptyFileTreeSelectionState();
const b = createEmptyFileTreeSelectionState();
a.selectedRowIds.add("doc:a");
expect(a.selectedRowIds.has("doc:a")).toBe(true);
expect(b.selectedRowIds.has("doc:a")).toBe(false);
expect(a.selectedRowIds).not.toBe(b.selectedRowIds);
});
});
@@ -0,0 +1,46 @@
import type { FileTreeSelectionState } from "./selection";
export type FileTreeSelectionSnapshotPayload = {
selectedRowIds: readonly string[];
anchorRowId: string | null;
focusedRowId: string | null;
};
export function createEmptyFileTreeSelectionState(): FileTreeSelectionState {
return {
selectedRowIds: new Set<string>(),
anchorRowId: null,
focusedRowId: null,
};
}
export function materializeRendererSelectionSnapshot(input: {
payload: FileTreeSelectionSnapshotPayload;
hasRowId: (rowId: string) => boolean;
}): FileTreeSelectionState {
const selectedRowIds = new Set(
input.payload.selectedRowIds.filter((rowId) => input.hasRowId(rowId)),
);
return {
selectedRowIds,
anchorRowId:
input.payload.anchorRowId && input.hasRowId(input.payload.anchorRowId)
? input.payload.anchorRowId
: null,
focusedRowId:
input.payload.focusedRowId && input.hasRowId(input.payload.focusedRowId)
? input.payload.focusedRowId
: null,
};
}
export function resolveActiveFileTreeSelection(input: {
preferRendererSnapshot: boolean;
legacySelection: FileTreeSelectionState;
rendererSelection: FileTreeSelectionState;
}): FileTreeSelectionState {
return input.preferRendererSnapshot
? input.rendererSelection
: input.legacySelection;
}
+238 -9
View File
@@ -3,9 +3,13 @@ import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import {
buildFileTreeShellDeletePreflightPayload,
buildFileTreeShellInternalDropPreflightPayload,
buildFileTreeShellPastePreflightPayload,
buildFileTreeShellUploadTargetPreflightPayload,
buildFileTreeShellRowById,
buildFileTreeShellVisibleRowIds,
computeFileTreeShellDeleteTargets,
collectFileTreeShellAssetHints,
getOrderedFileTreeShellRows,
inferFileTreeShellTargetDocumentId,
resolveFileTreeShellMindmapTargetId,
@@ -273,22 +277,247 @@ describe("file-tree shell helpers", () => {
).toEqual(["doc:doc_root", "asset:pdf_1"]);
});
it("删除目标计算应跳过被父页面覆盖的附件", () => {
it("内部拖放 preflight payload 应只收集 Rust 所需的行与父子快照", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = computeFileTreeShellDeleteTargets({
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
const result = buildFileTreeShellInternalDropPreflightPayload({
workspaceId: "ws_1",
copy: false,
targetDocumentId: null,
targetRowId: "asset-folder:mind_1",
focusedRowId: null,
activeDocId: null,
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
rowById,
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
parentById: new Map([["doc_root", null]]),
parentById: new Map([
["doc_root", null],
["doc_child", "doc_root"],
]),
});
expect(result.docIds).toEqual(["doc_root"]);
expect(result.assetIds).toEqual([]);
expect(result.assetHints).toEqual([]);
expect(result).toEqual({
workspaceId: "ws_1",
copy: false,
targetDocumentId: null,
targetRowId: "asset-folder:mind_1",
focusedRowId: null,
activeDocumentId: null,
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
rows: [
{
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
},
{
rowId: "asset:asset_child_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "mindmaps/mind_1/assets/node.png",
},
{
rowId: "doc:doc_root",
rowKind: "doc",
documentId: "doc_root",
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
},
],
documentParents: [
{ documentId: "doc_root", parentId: null },
{ documentId: "doc_child", parentId: "doc_root" },
],
});
});
it("删除 preflight payload 应只收集选中行与父子快照", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = buildFileTreeShellDeletePreflightPayload({
workspaceId: "ws_1",
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
rowById,
parentById: new Map([
["doc_root", null],
["doc_child", "doc_root"],
]),
});
expect(result).toEqual({
workspaceId: "ws_1",
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
rows: [
{
rowId: "doc:doc_root",
rowKind: "doc",
documentId: "doc_root",
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
},
],
documentParents: [
{ documentId: "doc_root", parentId: null },
{ documentId: "doc_child", parentId: "doc_root" },
],
});
});
it("粘贴 preflight payload 应只收集剪贴板行与当前 focused 目标行", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = buildFileTreeShellPastePreflightPayload({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocId: "doc_active",
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
rowById,
});
expect(result).toEqual({
workspaceId: "ws_1",
targetDocumentId: null,
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
rows: [
{
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
},
{
rowId: "index:doc_root",
rowKind: "index",
documentId: "doc_root",
assetId: null,
assetDocumentId: null,
assetType: null,
storagePath: null,
},
{
rowId: "asset:pdf_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "pdf_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "uploads/guide.pdf",
},
],
});
});
it("上传目标 preflight payload 应只收集目标行、focused 行与文档工作区快照", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
const result = buildFileTreeShellUploadTargetPreflightPayload({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: "asset-folder:mind_1",
activeDocId: "doc_active",
rowById,
documentWorkspaceById: new Map([
["doc_root", "ws_1"],
["doc_active", "ws_active"],
]),
});
expect(result).toEqual({
workspaceId: "ws_fallback",
targetDocumentId: null,
targetRowId: "asset:asset_child_1",
focusedRowId: "asset-folder:mind_1",
activeDocumentId: "doc_active",
rows: [
{
rowId: "asset:asset_child_1",
rowKind: "asset",
documentId: "doc_root",
assetId: "asset_child_1",
assetDocumentId: "doc_root",
assetType: "file",
storagePath: "mindmaps/mind_1/assets/node.png",
},
{
rowId: "asset-folder:mind_1",
rowKind: "asset-folder",
documentId: "doc_root",
assetId: "mind_1",
assetDocumentId: "doc_root",
assetType: "mindmap",
storagePath: "mindmaps/mind_1/mindmap.json",
},
],
documentWorkspaces: [
{ documentId: "doc_root", workspaceId: "ws_1" },
{ documentId: "doc_active", workspaceId: "ws_active" },
],
});
});
it("应能按 assetId 从 shell row map 回填 asset 与 asset-folder 的提示元数据", () => {
const rowById = buildFileTreeShellRowById({
fileTreeItems: [...fileTreeItems],
nodeById,
assetById,
});
expect(
collectFileTreeShellAssetHints({
rowById,
assetIds: ["mind_1", "pdf_1", "missing"],
}).map((asset) => asset.id),
).toEqual(["mind_1", "pdf_1"]);
});
});
+245 -64
View File
@@ -8,7 +8,6 @@ import {
} from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import { filterTopLevelDocIds } from "./dnd";
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
@@ -21,10 +20,52 @@ export type FileTreeShellRow = {
asset: MediaAsset | null;
};
export type FileTreeShellDeleteTargets = {
docIds: string[];
assetIds: string[];
assetHints: MediaAsset[];
export type FileTreeShellInternalDropPreflightRow = {
rowId: string;
rowKind: FileTreeShellRowKind;
documentId: string | null;
assetId: string | null;
assetDocumentId: string | null;
assetType: string | null;
storagePath: string | null;
};
export type FileTreeShellInternalDropPreflightPayload = {
workspaceId: string | null;
copy: boolean;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
documentParents: Array<{ documentId: string; parentId: string | null }>;
};
export type FileTreeShellDeletePreflightPayload = {
workspaceId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
documentParents: Array<{ documentId: string; parentId: string | null }>;
};
export type FileTreeShellPastePreflightPayload = {
workspaceId: string | null;
targetDocumentId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rowIds: string[];
rows: FileTreeShellInternalDropPreflightRow[];
};
export type FileTreeShellUploadTargetPreflightPayload = {
workspaceId: string | null;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocumentId: string | null;
rows: FileTreeShellInternalDropPreflightRow[];
documentWorkspaces: Array<{ documentId: string; workspaceId: string | null }>;
};
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
@@ -133,65 +174,6 @@ export function resolveFileTreeShellMindmapTargetId(
return null;
}
export function computeFileTreeShellDeleteTargets(input: {
visibleRowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
selectedRowIds: ReadonlySet<string>;
parentById: Map<string, string | null>;
}): FileTreeShellDeleteTargets {
const rows = getOrderedFileTreeShellRows({
rowIds: input.selectedRowIds,
visibleRowIds: input.visibleRowIds,
rowById: input.rowById,
});
const docCandidates: string[] = [];
const assetCandidates: string[] = [];
const assetDocIdByAssetId = new Map<string, string>();
const assetHintById = new Map<string, MediaAsset>();
rows.forEach((row) => {
if (row.rowKind === "doc" || row.rowKind === "index") {
docCandidates.push(row.documentId);
return;
}
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
assetCandidates.push(row.assetId);
assetDocIdByAssetId.set(row.assetId, row.documentId);
if (row.asset) {
assetHintById.set(row.assetId, row.asset);
}
}
});
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
const docIdSet = new Set(docIds);
const seenAssets = new Set<string>();
const assetIds: string[] = [];
const assetHints: MediaAsset[] = [];
assetCandidates.forEach((assetId) => {
if (!assetId || seenAssets.has(assetId)) {
return;
}
seenAssets.add(assetId);
const ownerDocId = assetDocIdByAssetId.get(assetId);
if (ownerDocId && docIdSet.has(ownerDocId)) {
return;
}
assetIds.push(assetId);
const assetHint = assetHintById.get(assetId);
if (assetHint) {
assetHints.push(assetHint);
}
});
return { docIds, assetIds, assetHints };
}
export function inferFileTreeShellTargetDocumentId(input: {
focusedRowId: string | null;
rowById: Map<string, FileTreeShellRow>;
@@ -205,3 +187,202 @@ export function inferFileTreeShellTargetDocumentId(input: {
}
return input.activeDocId || null;
}
function normalizeShellText(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function buildFileTreeShellDropPreflightRow(
row: FileTreeShellRow,
): FileTreeShellInternalDropPreflightRow {
return {
rowId: row.rowId,
rowKind: row.rowKind,
documentId: normalizeShellText(row.documentId),
assetId: normalizeShellText(row.assetId),
assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
assetType: normalizeShellText(row.asset?.asset_type ?? null),
storagePath: normalizeShellText(row.asset?.storage_path ?? null),
};
}
export function buildFileTreeShellInternalDropPreflightPayload(input: {
workspaceId: string | null;
copy: boolean;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocId: string | null;
rowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
parentById: Map<string, string | null>;
}): FileTreeShellInternalDropPreflightPayload {
const rowIdSet = new Set<string>();
const appendRowId = (value: string | null | undefined) => {
const rowId = normalizeShellText(value);
if (rowId) {
rowIdSet.add(rowId);
}
};
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
const targetRowId = normalizeShellText(input.targetRowId);
const focusedRowId = normalizeShellText(input.focusedRowId);
appendRowId(targetRowId);
appendRowId(focusedRowId);
rowIds.forEach(appendRowId);
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
copy: input.copy,
targetDocumentId: normalizeShellText(input.targetDocumentId),
targetRowId,
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rowIds,
rows,
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
documentId,
parentId: normalizeShellText(parentId),
})),
};
}
export function buildFileTreeShellDeletePreflightPayload(input: {
workspaceId: string | null;
rowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
parentById: Map<string, string | null>;
}): FileTreeShellDeletePreflightPayload {
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
const rowIdSet = new Set<string>();
rowIds.forEach((rowId) => {
const normalized = normalizeShellText(rowId);
if (normalized) {
rowIdSet.add(normalized);
}
});
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
rowIds,
rows,
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
documentId,
parentId: normalizeShellText(parentId),
})),
};
}
export function buildFileTreeShellPastePreflightPayload(input: {
workspaceId: string | null;
targetDocumentId: string | null;
focusedRowId: string | null;
activeDocId: string | null;
rowIds: readonly string[];
rowById: Map<string, FileTreeShellRow>;
}): FileTreeShellPastePreflightPayload {
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
const rowIdSet = new Set<string>();
const focusedRowId = normalizeShellText(input.focusedRowId);
if (focusedRowId) {
rowIdSet.add(focusedRowId);
}
rowIds.forEach((rowId) => {
const normalized = normalizeShellText(rowId);
if (normalized) {
rowIdSet.add(normalized);
}
});
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
targetDocumentId: normalizeShellText(input.targetDocumentId),
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rowIds,
rows,
};
}
export function buildFileTreeShellUploadTargetPreflightPayload(input: {
workspaceId: string | null;
targetDocumentId: string | null;
targetRowId: string | null;
focusedRowId: string | null;
activeDocId: string | null;
rowById: Map<string, FileTreeShellRow>;
documentWorkspaceById: Map<string, string | null>;
}): FileTreeShellUploadTargetPreflightPayload {
const rowIdSet = new Set<string>();
const appendRowId = (value: string | null | undefined) => {
const rowId = normalizeShellText(value);
if (rowId) {
rowIdSet.add(rowId);
}
};
const targetRowId = normalizeShellText(input.targetRowId);
const focusedRowId = normalizeShellText(input.focusedRowId);
appendRowId(targetRowId);
appendRowId(focusedRowId);
const rows = Array.from(rowIdSet)
.map((rowId) => input.rowById.get(rowId) ?? null)
.filter((row): row is FileTreeShellRow => Boolean(row))
.map(buildFileTreeShellDropPreflightRow);
return {
workspaceId: normalizeShellText(input.workspaceId),
targetDocumentId: normalizeShellText(input.targetDocumentId),
targetRowId,
focusedRowId,
activeDocumentId: normalizeShellText(input.activeDocId),
rows,
documentWorkspaces: Array.from(input.documentWorkspaceById.entries()).map(
([documentId, workspaceId]) => ({
documentId,
workspaceId: normalizeShellText(workspaceId),
}),
),
};
}
export function collectFileTreeShellAssetHints(input: {
rowById: Map<string, FileTreeShellRow>;
assetIds: readonly string[];
}): MediaAsset[] {
const hints: MediaAsset[] = [];
const seen = new Set<string>();
input.assetIds.forEach((assetId) => {
const normalized = normalizeShellText(assetId);
if (!normalized || seen.has(normalized)) {
return;
}
const asset =
input.rowById.get(`asset:${normalized}`)?.asset ??
input.rowById.get(`asset-folder:${normalized}`)?.asset ??
null;
if (!asset) {
return;
}
seen.add(normalized);
hints.push(asset);
});
return hints;
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
import { resolveKernelFileTreeProjection } from "./kernel-file-tree";
const mockBuildDocumentBridgeContextWithActor = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQuery = vi.fn();
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
mockBuildDocumentBridgeContextWithActor(...args),
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
}));
describe("resolveKernelFileTreeProjection", () => {
beforeEach(() => {
mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
requestId: "req_1",
traceId: "trace_1",
});
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
mockExecuteRustBridgeQuery.mockReset().mockResolvedValue({
projectionId: "kernel_projection:file_tree:page_root",
projection: "file_tree",
rootNodeId: "page_root",
items: [],
edges: [],
});
});
it("把搜索词传入 Rust kernel.project_view,而不是交给宿主裁剪", async () => {
await resolveKernelFileTreeProjection({
client: { query: vi.fn() } as unknown as ConvexHttpClient,
request: new Request("http://127.0.0.1:3000/api/tree/projections/file"),
workspaceId: "ws_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
dataset: {
active_workspace_id: "ws_1",
documents: [],
},
rootNodeId: "page_root",
depth: 2,
query: " 预算 ",
maxResults: 12,
});
expect(mockBuildDocumentQueryEnvelope).toHaveBeenCalledWith({
name: "kernel.project_view",
payload: expect.objectContaining({
projection: "file_tree",
workspaceId: "ws_1",
rootNodeId: "page_root",
depth: 2,
query: "预算",
maxResults: 12,
}),
});
expect(mockExecuteRustBridgeQuery).toHaveBeenCalledTimes(1);
});
});
@@ -16,12 +16,19 @@ export async function resolveKernelFileTreeProjection(input: {
dataset: SidebarDatasetListQueryResult;
rootNodeId?: string | null;
depth?: number | null;
query?: string | null;
maxResults?: number | null;
}): Promise<KernelFileTreeProjection> {
const context = buildDocumentBridgeContextWithActor({
request: input.request,
actor: input.actor,
workspaceId: input.workspaceId,
});
const query = input.query?.trim() || null;
const maxResults =
typeof input.maxResults === "number" && Number.isFinite(input.maxResults)
? Math.max(1, Math.floor(input.maxResults))
: null;
return executeRustBridgeQuery<KernelFileTreeProjection>({
context,
@@ -32,6 +39,8 @@ export async function resolveKernelFileTreeProjection(input: {
workspaceId: input.workspaceId,
rootNodeId: input.rootNodeId ?? null,
depth: input.depth ?? null,
query,
maxResults,
includeEdges: true,
includeContent: false,
nodeTypes: ["page"],
@@ -266,6 +266,458 @@ describe("tree-stream/server", () => {
expect(loadSnapshot).toHaveBeenCalledTimes(2);
});
it("单条新 domain event 携带 streamDelta 时,应直接发 delta", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce({
command_logs: [],
domain_events: [
{
event_id: "evt_1",
created_at: "2026-04-24T00:00:01Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:01Z",
})
.mockResolvedValueOnce({
command_logs: [],
domain_events: [
{
event_id: "evt_2",
created_at: "2026-04-24T00:00:02Z",
payload: {
command_name: "tree.node.rename",
streamDelta: {
op: "upsert_document",
document: {
id: "page_2",
title: "新标题",
},
},
},
},
{
event_id: "evt_1",
created_at: "2026-04-24T00:00:01Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:02Z",
});
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:02Z",
id: "domain_event:evt_2",
}),
data: {
op: "upsert_document",
document: {
id: "page_2",
title: "新标题",
},
},
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("单条资源 domain event 携带 upsert_assets 时,应直接发 delta", async () => {
const streamDelta = {
op: "upsert_assets",
upsertAssets: [
{
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",
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
},
],
};
const loadOverview = vi
.fn()
.mockResolvedValueOnce({
command_logs: [],
domain_events: [
{
event_id: "evt_1",
created_at: "2026-04-24T00:00:01Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:01Z",
})
.mockResolvedValueOnce({
command_logs: [],
domain_events: [
{
event_id: "evt_2",
created_at: "2026-04-24T00:00:02Z",
payload: {
schema: "mnote.tree.domain_event",
eventType: "tree.resource.moved",
streamDelta,
},
},
{
event_id: "evt_1",
created_at: "2026-04-24T00:00:01Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:02Z",
});
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
data: streamDelta,
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("单条新 domain event 缺少可识别 streamDelta 时,应保守回退 resync", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce({
command_logs: [],
domain_events: [
{
event_id: "evt_1",
created_at: "2026-04-24T00:00:01Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:01Z",
})
.mockResolvedValueOnce({
command_logs: [],
domain_events: [
{
event_id: "evt_2",
created_at: "2026-04-24T00:00:02Z",
payload: {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.node.unknown",
},
},
{
event_id: "evt_1",
created_at: "2026-04-24T00:00:01Z",
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:02Z",
});
const loadSnapshot = vi
.fn()
.mockResolvedValueOnce({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
})
.mockResolvedValueOnce({
requestId: "req_2",
traceId: "trace_2",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_2" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_2" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "resync",
payload: {
kind: "resync",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:02Z",
id: "domain_event:evt_2",
}),
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(2);
});
it("同一命令的 command log 与 domain event 同时推进且 delta 一致时,应发一次 delta", async () => {
const streamDelta = {
op: "upsert_document",
document: { id: "page_2", title: "同一标题" },
};
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce({
command_logs: [
{
id: "clog_2",
command_id: "cmd_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "tree.node.rename",
payload: { streamDelta },
},
{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
],
domain_events: [
{
event_id: "evt_2",
command_id: "cmd_2",
created_at: "2026-04-24T00:00:02Z",
payload: { streamDelta },
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:02Z",
});
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
cursor: JSON.stringify({
createdAt: "2026-04-24T00:00:02Z",
id: "clog_2",
}),
data: streamDelta,
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("上一帧 cursor 来自 command log 时,应按时间排除旧 domain event 后再做双写去重", async () => {
const streamDelta = {
op: "move_document",
documentId: "page_2",
parentId: "page_1",
sortOrder: 2,
};
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce({
command_logs: [
{
id: "clog_2",
command_id: "cmd_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "tree.subtree.move",
payload: { streamDelta },
},
{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
],
domain_events: [
{
event_id: "evt_2",
command_id: "cmd_2",
created_at: "2026-04-24T00:00:02Z",
payload: { streamDelta },
},
{
event_id: "evt_1",
command_id: "cmd_1",
created_at: "2026-04-24T00:00:01Z",
payload: {
streamDelta: {
op: "noop",
},
},
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:02Z",
});
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
data: streamDelta,
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("command log 与 domain event 同时推进且 delta 不一致时,应回退 resync 避免漏发", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce({
command_logs: [
{
id: "clog_2",
created_at: "2026-04-24T00:00:03Z",
command_name: "tree.node.rename",
payload: {
streamDelta: {
op: "upsert_document",
document: { id: "page_2", title: "命令标题" },
},
},
},
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
],
domain_events: [
{
event_id: "evt_2",
created_at: "2026-04-24T00:00:02Z",
payload: {
streamDelta: {
op: "remove_document",
documentId: "page_3",
},
},
},
],
has_more: false,
next_cursor: null,
generated_at: "2026-04-24T00:00:03Z",
});
const loadSnapshot = vi
.fn()
.mockResolvedValueOnce({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
})
.mockResolvedValueOnce({
requestId: "req_2",
traceId: "trace_2",
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_2" }] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_2" }] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames).toHaveLength(2);
expect(frames[1]).toMatchObject({
event: "resync",
payload: {
kind: "resync",
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(2);
});
it("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
const loadOverview = vi
.fn()
@@ -374,7 +826,7 @@ describe("tree-stream/server", () => {
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("move 这类附带 replace_documents 的新命令应直接发 delta,而不是触发 resync", async () => {
it("move 这类附带 move_document 的新命令应直接发 delta,而不是触发 resync", async () => {
const sidebarSnapshot = {
activeWorkspaceId: "ws_1",
workspaces: [],
@@ -428,8 +880,11 @@ describe("tree-stream/server", () => {
payload: {
documentId: "page_1",
streamDelta: {
op: "replace_documents",
documents: sidebarSnapshot.documents,
op: "move_document",
documentId: "page_1",
parentId: null,
sortOrder: 0,
updatedAt: "2026-04-24T00:01:00Z",
},
},
},
@@ -460,12 +915,82 @@ describe("tree-stream/server", () => {
payload: {
kind: "delta",
data: {
op: "replace_documents",
documents: expect.arrayContaining([
op: "move_document",
documentId: "page_1",
parentId: null,
sortOrder: 0,
updatedAt: "2026-04-24T00:01:00Z",
},
},
});
expect(loadSnapshot).toHaveBeenCalledTimes(1);
});
it("copy 这类附带 upsert_documents 的新命令应保留批量文档字段", async () => {
const loadOverview = vi
.fn()
.mockResolvedValueOnce(
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
)
.mockResolvedValueOnce(
buildOverview([
{
id: "clog_2",
created_at: "2026-04-24T00:00:02Z",
command_name: "tree.subtree.copy",
payload: {
streamDelta: {
op: "upsert_documents",
upsertDocuments: [
{
id: "copy_1",
workspace_id: "ws_1",
title: "Copy",
parent_id: null,
sort_order: 2,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-24T00:00:02Z",
updated_at: "2026-04-24T00:00:02Z",
},
],
},
},
},
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
]),
);
const loadSnapshot = vi.fn().mockResolvedValue({
requestId: "req_1",
traceId: "trace_1",
data: { activeWorkspaceId: "ws_1", documents: [] },
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
});
const frames = await collectFrames(
streamTreeFrames({
workspaceId: "ws_1",
pollMs: 1,
maxPolls: 1,
loadOverview,
loadSnapshot,
sleep: vi.fn().mockResolvedValue(undefined),
}),
);
expect(frames[1]).toMatchObject({
event: "delta",
payload: {
kind: "delta",
data: {
op: "upsert_documents",
upsertDocuments: [
expect.objectContaining({
id: "page_1",
id: "copy_1",
workspace_id: "ws_1",
}),
]),
],
},
},
});
@@ -6,6 +6,8 @@ export type TreeStreamEventName = "snapshot" | "delta" | "resync";
export interface TreeStreamCommandLogCursorRow {
id?: string | null;
command_id?: string | null;
commandId?: string | null;
created_at?: string | null;
command_name?: string | null;
commandName?: string | null;
@@ -65,8 +67,11 @@ type DecodedTreeStreamCursor = {
type TreeStreamDomainEventCursorRow = {
id?: string | null;
event_id?: string | null;
command_id?: string | null;
commandId?: string | null;
created_at?: string | null;
createdAt?: string | null;
payload?: unknown;
};
const TREE_STREAM_NOOP_COMMANDS = new Set([
@@ -219,11 +224,110 @@ function readCommandPayloadDelta(row: TreeStreamCommandLogCursorRow): TreeStream
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
parentId: typeof candidate.parentId === "string" ? candidate.parentId : candidate.parentId === null ? null : undefined,
sortOrder:
typeof candidate.sortOrder === "number" && Number.isFinite(candidate.sortOrder)
? candidate.sortOrder
: undefined,
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
upsertDocuments: Array.isArray(candidate.upsertDocuments)
? (candidate.upsertDocuments as TreeStreamDeltaEvent["upsertDocuments"])
: null,
upsertAssets: Array.isArray(candidate.upsertAssets)
? (candidate.upsertAssets as TreeStreamDeltaEvent["upsertAssets"])
: null,
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
};
}
function readStreamDeltaCandidate(candidate: unknown): TreeStreamDeltaEvent | null {
if (!isRecord(candidate) || typeof candidate.op !== "string") {
return null;
}
return {
op: candidate.op as TreeStreamDeltaEvent["op"],
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
parentId: typeof candidate.parentId === "string" ? candidate.parentId : candidate.parentId === null ? null : undefined,
sortOrder:
typeof candidate.sortOrder === "number" && Number.isFinite(candidate.sortOrder)
? candidate.sortOrder
: undefined,
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
upsertDocuments: Array.isArray(candidate.upsertDocuments)
? (candidate.upsertDocuments as TreeStreamDeltaEvent["upsertDocuments"])
: null,
upsertAssets: Array.isArray(candidate.upsertAssets)
? (candidate.upsertAssets as TreeStreamDeltaEvent["upsertAssets"])
: null,
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
};
}
function readDomainEventPayloadDelta(row: TreeStreamDomainEventCursorRow): TreeStreamDeltaEvent | null {
if (!isRecord(row.payload)) {
return null;
}
return readStreamDeltaCandidate(row.payload.streamDelta ?? row.payload.stream_delta);
}
function readCommandRowCommandId(row: TreeStreamCommandLogCursorRow): string | null {
const raw =
typeof row.command_id === "string"
? row.command_id
: typeof row.commandId === "string"
? row.commandId
: typeof row.id === "string"
? row.id
: "";
const trimmed = raw.trim();
return trimmed || null;
}
function readDomainEventCommandId(row: TreeStreamDomainEventCursorRow): string | null {
const raw =
typeof row.command_id === "string"
? row.command_id
: typeof row.commandId === "string"
? row.commandId
: "";
const trimmed = raw.trim();
return trimmed || null;
}
function resolveMatchingCommandDomainEventDelta(input: {
commandRows: TreeStreamCommandLogCursorRow[];
domainEventRows: TreeStreamDomainEventCursorRow[];
commandDrifted: boolean;
domainEventDrifted: boolean;
}): TreeStreamDeltaEvent | null {
if (
input.commandDrifted ||
input.commandRows.length !== 1 ||
input.domainEventRows.length !== 1
) {
return null;
}
const commandId = readCommandRowCommandId(input.commandRows[0] ?? {});
const eventCommandId = readDomainEventCommandId(input.domainEventRows[0] ?? {});
if (!commandId || commandId !== eventCommandId) {
return null;
}
const commandDelta = readCommandPayloadDelta(input.commandRows[0] ?? {});
const eventDelta = readDomainEventPayloadDelta(input.domainEventRows[0] ?? {});
if (!commandDelta || !eventDelta) {
return null;
}
return JSON.stringify(commandDelta) === JSON.stringify(eventDelta) ? eventDelta : null;
}
function collectNewCommandLogs(input: {
rows: TreeStreamCommandLogCursorRow[];
previousCursor: string | null;
@@ -249,6 +353,77 @@ function collectNewCommandLogs(input: {
};
}
const newerRows = input.rows.filter((row) => {
const createdAt = typeof row.created_at === "string" ? row.created_at.trim() : "";
return createdAt > previousCursor.createdAt;
});
if (newerRows.length < input.rows.length) {
return {
rows: newerRows,
drifted: false,
};
}
return {
rows: input.rows,
drifted: input.rows.length > 0,
};
}
function collectNewDomainEvents(input: {
rows: TreeStreamDomainEventCursorRow[];
previousCursor: string | null;
}) {
const previousCursor = decodeTreeStreamCursor(input.previousCursor);
if (!previousCursor) {
return {
rows: input.rows,
drifted: false,
};
}
const previousId = previousCursor.id.startsWith("domain_event:")
? previousCursor.id.slice("domain_event:".length)
: previousCursor.id;
const previousIndex = input.rows.findIndex((row) => {
const id =
typeof row.event_id === "string"
? row.event_id.trim()
: typeof row.id === "string"
? row.id.trim()
: "";
const createdAt =
typeof row.created_at === "string"
? row.created_at.trim()
: typeof row.createdAt === "string"
? row.createdAt.trim()
: "";
return id === previousId && createdAt === previousCursor.createdAt;
});
if (previousIndex >= 0) {
return {
rows: input.rows.slice(0, previousIndex),
drifted: false,
};
}
const newerRows = input.rows.filter((row) => {
const createdAt =
typeof row.created_at === "string"
? row.created_at.trim()
: typeof row.createdAt === "string"
? row.createdAt.trim()
: "";
return createdAt > previousCursor.createdAt;
});
if (newerRows.length < input.rows.length) {
return {
rows: newerRows,
drifted: false,
};
}
return {
rows: input.rows,
drifted: input.rows.length > 0,
@@ -353,6 +528,56 @@ export async function* streamTreeFrames(
rows,
previousCursor: cursor,
});
const eventRows = Array.isArray(overview.domain_events)
? (overview.domain_events as TreeStreamDomainEventCursorRow[])
: [];
const newEventRows = collectNewDomainEvents({
rows: eventRows,
previousCursor: cursor,
});
const hasNewCommandRows = newRows.rows.length > 0;
const hasNewDomainEventRows = newEventRows.rows.length > 0;
if (hasNewCommandRows && hasNewDomainEventRows) {
const delta = resolveMatchingCommandDomainEventDelta({
commandRows: newRows.rows,
domainEventRows: newEventRows.rows,
commandDrifted: newRows.drifted,
domainEventDrifted: newEventRows.drifted,
});
if (delta) {
cursor = nextCursor;
yield {
event: "delta",
payload: buildTreeStreamDeltaEnvelope({
workspaceId: input.workspaceId,
rootNodeId: contract.rootNodeId,
projection: contract.projection,
cursor,
overview,
snapshot,
delta,
}),
};
continue;
}
snapshot = await input.loadSnapshot();
cursor = nextCursor;
yield {
event: "resync",
payload: buildTreeStreamEnvelope({
kind: "resync",
workspaceId: input.workspaceId,
rootNodeId: contract.rootNodeId,
projection: contract.projection,
cursor,
overview,
snapshot,
}),
};
continue;
}
if (!newRows.drifted && newRows.rows.length === 1) {
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
if (delta) {
@@ -373,6 +598,26 @@ export async function* streamTreeFrames(
}
}
if (!newEventRows.drifted && newRows.rows.length === 0 && newEventRows.rows.length === 1) {
const delta = readDomainEventPayloadDelta(newEventRows.rows[0] ?? {});
if (delta) {
cursor = nextCursor;
yield {
event: "delta",
payload: buildTreeStreamDeltaEnvelope({
workspaceId: input.workspaceId,
rootNodeId: contract.rootNodeId,
projection: contract.projection,
cursor,
overview,
snapshot,
delta,
}),
};
continue;
}
}
snapshot = await input.loadSnapshot();
cursor = nextCursor;
@@ -346,6 +346,84 @@ describe("tree-stream/tree-delta", () => {
});
});
it("支持 upsert_documents 批量新增复制出的子树", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
op: "upsert_documents",
upsertDocuments: [
{
id: "copy_root",
workspace_id: "ws_1",
title: "Copy Root",
parent_id: null,
sort_order: 2,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-18T00:10:00Z",
updated_at: "2026-04-18T00:10:00Z",
},
{
id: "copy_child",
workspace_id: "ws_1",
title: "Copy Child",
parent_id: "copy_root",
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-18T00:10:00Z",
updated_at: "2026-04-18T00:10:00Z",
},
],
});
expect(next.documents.map((item) => item.id)).toEqual([
"root",
"child",
"copy_root",
"copy_child",
]);
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
"root",
"child",
"copy_root",
"copy_child",
]);
expect(
next.kernelSidebarProjection.items.find((item) => item.nodeId === "copy_child"),
).toMatchObject({
parentNodeId: "copy_root",
depth: 1,
});
});
it("支持 move_document 细粒度更新父节点与排序字段", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
op: "move_document",
documentId: "child",
parentId: null,
sortOrder: 0,
updatedAt: "2026-04-18T00:10:00Z",
});
expect(next.documents.find((item) => item.id === "child")).toMatchObject({
id: "child",
parent_id: null,
sort_order: 0,
updated_at: "2026-04-18T00:10:00Z",
});
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
"root",
"child",
]);
expect(
next.kernelSidebarProjection.items.find((item) => item.nodeId === "child"),
).toMatchObject({
parentNodeId: null,
depth: 0,
});
});
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
const next = applyTreeStreamDelta(baseSidebarData, {
op: "replace_sidebar",
@@ -478,4 +556,47 @@ describe("tree-stream/tree-delta", () => {
}),
});
});
it("支持 upsert_assets 更新资源归属并重建 file_tree projection", () => {
const next = applyTreeStreamDeltaToProjectionState({
projection: "file_tree",
base: fileTreeProjectionBase,
event: {
op: "upsert_assets",
upsertAssets: [
{
id: "asset_pdf",
workspace_id: "ws_1",
document_id: "child",
asset_type: "file",
file_url: "/manual.pdf",
thumbnail_url: "/manual.pdf",
bucket: null,
storage_path: "documents/child/manual.pdf",
file_name: "manual.pdf",
file_size: 1024,
mime_type: "application/pdf",
ocr_text: null,
ocr_status: null,
created_at: "2026-04-18T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
},
],
},
});
expect(next.sidebar.mediaAssets?.find((asset) => asset.id === "asset_pdf")).toMatchObject({
document_id: "child",
updated_at: "2026-04-26T00:00:00Z",
});
expect(
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_pdf"),
).toMatchObject({
parentNodeId: "child",
resourceMeta: expect.objectContaining({
documentId: "child",
resourceKind: "pdf",
}),
});
});
});
@@ -24,7 +24,10 @@ export type TreeStreamDocumentPatch =
export type TreeStreamDeltaOp =
| "noop"
| "upsert_document"
| "upsert_documents"
| "upsert_assets"
| "remove_document"
| "move_document"
| "replace_documents"
| "replace_sidebar";
@@ -33,6 +36,11 @@ export type TreeStreamDeltaEvent = {
node?: TreeStreamDocumentPatch | null;
document?: TreeStreamDocumentPatch | null;
documentId?: string | null;
parentId?: string | null;
sortOrder?: number | null;
updatedAt?: string | null;
upsertDocuments?: TreeStreamDocumentPatch[] | null;
upsertAssets?: MediaAsset[] | null;
documents?: DocumentRecord[] | null;
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
};
@@ -161,6 +169,42 @@ function isCompleteDocumentRecord(value: TreeStreamDocumentPatch): value is Docu
);
}
function isCompleteMediaAsset(value: unknown): value is MediaAsset {
if (!value || typeof value !== "object") {
return false;
}
const asset = value as MediaAsset;
return (
typeof asset.id === "string" &&
typeof asset.workspace_id === "string" &&
typeof asset.document_id === "string" &&
typeof asset.asset_type === "string" &&
typeof asset.created_at === "string" &&
typeof asset.updated_at === "string" &&
"file_name" in value &&
"file_url" in value &&
"thumbnail_url" in value &&
"file_size" in value &&
"mime_type" in value
);
}
function buildSidebarFromAssets(input: {
base: SidebarInitialData;
mediaAssets: MediaAsset[];
}): SidebarInitialData {
const next = cloneSidebarData(input.base);
next.mediaAssets = [...input.mediaAssets];
next.kernelFileTreeProjection = buildKernelFileTreeProjection({
documents: next.documents,
mediaAssets: next.mediaAssets,
mindmapAssets: next.mindmapAssets,
tableAssets: next.tableAssets,
mindmapAssetChildren: next.mindmapAssetChildren,
});
return next;
}
export function deriveTreeRendererDeltaState(input: {
projection: TreeRendererProjection;
sidebar: SidebarInitialData;
@@ -230,6 +274,45 @@ export function applyTreeStreamDelta(
});
}
if (event.op === "upsert_documents") {
const patches = Array.isArray(event.upsertDocuments) ? event.upsertDocuments : [];
if (patches.length === 0) {
return base;
}
let changed = false;
const nextDocuments = [...base.documents];
for (const rawPatch of patches) {
if (!rawPatch || typeof rawPatch.id !== "string" || !rawPatch.id.trim()) {
continue;
}
const documentPatch = {
...rawPatch,
id: rawPatch.id.trim(),
} as TreeStreamDocumentPatch;
const existingIndex = nextDocuments.findIndex((item) => item.id === documentPatch.id);
if (existingIndex >= 0) {
nextDocuments[existingIndex] = {
...nextDocuments[existingIndex],
...documentPatch,
};
changed = true;
continue;
}
if (!isCompleteDocumentRecord(documentPatch)) {
continue;
}
nextDocuments.push(documentPatch);
changed = true;
}
if (!changed) {
return base;
}
return buildSidebarFromDocuments({
base,
documents: nextDocuments,
});
}
if (event.op === "remove_document") {
const documentId = normalizeDocumentId(event);
if (!documentId) {
@@ -252,6 +335,66 @@ export function applyTreeStreamDelta(
});
}
if (event.op === "move_document") {
const documentId = normalizeDocumentId(event);
if (!documentId) {
return base;
}
const existingIndex = base.documents.findIndex((item) => item.id === documentId);
if (existingIndex < 0) {
return base;
}
const nextDocuments = [...base.documents];
const existing = nextDocuments[existingIndex]!;
nextDocuments[existingIndex] = {
...existing,
parent_id: "parentId" in event ? (event.parentId ?? null) : existing.parent_id,
sort_order:
typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder)
? event.sortOrder
: existing.sort_order,
updated_at:
typeof event.updatedAt === "string" && event.updatedAt.trim()
? event.updatedAt.trim()
: existing.updated_at,
};
return buildSidebarFromDocuments({
base,
documents: nextDocuments,
});
}
if (event.op === "upsert_assets") {
const assets = Array.isArray(event.upsertAssets) ? event.upsertAssets : [];
if (assets.length === 0) {
return base;
}
let changed = false;
const nextAssets = [...(base.mediaAssets ?? [])];
for (const asset of assets) {
if (!isCompleteMediaAsset(asset)) {
continue;
}
const existingIndex = nextAssets.findIndex((item) => item.id === asset.id);
if (existingIndex >= 0) {
nextAssets[existingIndex] = {
...nextAssets[existingIndex],
...asset,
};
} else {
nextAssets.push(asset);
}
changed = true;
}
if (!changed) {
return base;
}
return buildSidebarFromAssets({
base,
mediaAssets: nextAssets,
});
}
return base;
}