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:
@@ -6,6 +6,7 @@ const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
||||
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
||||
const mockRecordBridgeCommandArtifacts = vi.fn();
|
||||
const mockRecordBridgeCommandFailureArtifacts = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
@@ -62,12 +63,112 @@ vi.mock("@/lib/documents/bridge", () => ({
|
||||
},
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
|
||||
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||
mockDocumentBridgeErrorResponse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||||
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
|
||||
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||
mockResolveRustBridgeCommandPlan(...args),
|
||||
executeRustBridgeMutationTransport: (...args: Parameters<typeof mockExecuteRustBridgeMutationTransport>) =>
|
||||
mockExecuteRustBridgeMutationTransport(...args),
|
||||
recordRustBridgeCommandArtifacts: (...args: Parameters<typeof mockRecordRustBridgeCommandArtifacts>) =>
|
||||
mockRecordRustBridgeCommandArtifacts(...args),
|
||||
readRustTreeDomainEventType: (plan: { argsJson?: Record<string, unknown> }) => {
|
||||
const eventPlan = plan.argsJson?.domainEventPlan as
|
||||
| {
|
||||
family?: string;
|
||||
eventType?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (eventPlan?.family === "tree" && typeof eventPlan.eventType === "string") {
|
||||
return eventPlan.eventType;
|
||||
}
|
||||
const hint = plan.argsJson?.domainEventHint as
|
||||
| {
|
||||
family?: string;
|
||||
eventType?: string;
|
||||
}
|
||||
| undefined;
|
||||
return hint?.family === "tree" && typeof hint.eventType === "string" ? hint.eventType : null;
|
||||
},
|
||||
materializeRustTreeDomainEventPlan: (input: {
|
||||
plan: { argsJson?: Record<string, unknown> };
|
||||
streamDelta?: Record<string, unknown> | null;
|
||||
}) => {
|
||||
const eventPlan = input.plan.argsJson?.domainEventPlan as
|
||||
| {
|
||||
family?: string;
|
||||
schema?: string;
|
||||
schemaVersion?: number;
|
||||
eventType?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (
|
||||
eventPlan?.family !== "tree" ||
|
||||
eventPlan.schema !== "mnote.tree.domain_event" ||
|
||||
eventPlan.schemaVersion !== 1 ||
|
||||
typeof eventPlan.eventType !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: eventPlan.eventType,
|
||||
...(input.streamDelta ? { streamDelta: input.streamDelta } : {}),
|
||||
};
|
||||
},
|
||||
materializeRustTreeStreamDelta: (input: { plan: { argsJson?: Record<string, unknown> }; result: unknown }) => {
|
||||
const hint = input.plan.argsJson?.streamDeltaHint as
|
||||
| {
|
||||
family?: string;
|
||||
kind?: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
if (hint?.family !== "tree" || !hint.kind) return null;
|
||||
const args = hint.args ?? {};
|
||||
const result = input.result as Record<string, unknown>;
|
||||
if (hint.kind === "document_result") {
|
||||
return result.document ? { op: "upsert_document", document: result.document } : null;
|
||||
}
|
||||
if (hint.kind === "upsert_document_patch") {
|
||||
return {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: args.documentId,
|
||||
...(args.patch as Record<string, unknown>),
|
||||
updated_at: result.updated_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (hint.kind === "move_document") {
|
||||
return {
|
||||
op: "move_document",
|
||||
documentId: args.documentId,
|
||||
parentId: result.parent_id ?? args.parentId ?? null,
|
||||
sortOrder: result.sort_order ?? args.sortOrder,
|
||||
updatedAt: result.updated_at,
|
||||
};
|
||||
}
|
||||
if (hint.kind === "remove_document") {
|
||||
return { op: "remove_document", documentId: args.documentId };
|
||||
}
|
||||
if (hint.kind === "noop") {
|
||||
return { op: "noop" };
|
||||
}
|
||||
if (hint.kind === "copy_result") {
|
||||
return {
|
||||
op: "upsert_documents",
|
||||
upsertDocuments: Array.isArray(result.items)
|
||||
? result.items.map((item) => item?.document).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
@@ -92,6 +193,7 @@ describe("/api/tree/commands route", () => {
|
||||
mockBuildDocumentCommandEnvelope.mockReset();
|
||||
mockResolveRustBridgeCommandPlan.mockReset();
|
||||
mockExecuteRustBridgeMutationTransport.mockReset();
|
||||
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
||||
mockRecordBridgeCommandArtifacts.mockReset();
|
||||
mockRecordBridgeCommandFailureArtifacts.mockReset();
|
||||
mockEnsureDocumentScaffold.mockReset();
|
||||
@@ -146,7 +248,17 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "document_result",
|
||||
args: { documentField: "document" },
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.created",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
id: "doc_new",
|
||||
@@ -158,6 +270,18 @@ describe("/api/tree/commands route", () => {
|
||||
is_template: false,
|
||||
created_at: "2026-04-23T00:00:00Z",
|
||||
updated_at: "2026-04-23T00:00:00Z",
|
||||
document: {
|
||||
id: "doc_new",
|
||||
workspace_id: "ws_root",
|
||||
title: "无标题",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-23T00:00:00Z",
|
||||
updated_at: "2026-04-23T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
@@ -196,25 +320,26 @@ describe("/api/tree/commands route", () => {
|
||||
}),
|
||||
);
|
||||
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
requestId: "req_tree_1",
|
||||
traceId: "trace_tree_1",
|
||||
}),
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.node.create",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: expect.objectContaining({
|
||||
id: "doc_new",
|
||||
workspace_id: "ws_root",
|
||||
title: "无标题",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
}),
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.node.create",
|
||||
functionName: "documents:createWithParentReference",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
id: "doc_new",
|
||||
workspace_id: "ws_root",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
|
||||
@@ -283,7 +408,21 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
},
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.subtree.moved",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -392,27 +531,18 @@ describe("/api/tree/commands route", () => {
|
||||
}),
|
||||
);
|
||||
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.subtree.move",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "replace_documents",
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
},
|
||||
{
|
||||
id: "parent_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.subtree.move",
|
||||
functionName: "documents:move",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
parent_id: "parent_1",
|
||||
sort_order: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -482,7 +612,21 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
},
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.subtree.moved",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -902,7 +1046,20 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "upsert_document_patch",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
patch: { title: "新标题" },
|
||||
},
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.renamed",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -949,19 +1106,16 @@ describe("/api/tree/commands route", () => {
|
||||
}),
|
||||
);
|
||||
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.node.rename",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: expect.objectContaining({
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
}),
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.node.rename",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
updated_at: "2026-04-23T00:00:00Z",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1000,7 +1154,17 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "remove_document",
|
||||
args: { documentId: "doc_1" },
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.archived",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -1042,17 +1206,15 @@ describe("/api/tree/commands route", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.node.archive",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "remove_document",
|
||||
documentId: "doc_1",
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.node.archive",
|
||||
}),
|
||||
result: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1112,7 +1274,17 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "noop",
|
||||
args: {},
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.embedded",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
revision: 8,
|
||||
@@ -1170,16 +1342,15 @@ describe("/api/tree/commands route", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.node.embed",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "noop",
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.node.embed",
|
||||
}),
|
||||
result: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1222,7 +1393,17 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "copy_result",
|
||||
args: { itemsField: "items", documentField: "document" },
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.subtree.copied",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
items: [
|
||||
@@ -1230,6 +1411,18 @@ describe("/api/tree/commands route", () => {
|
||||
oldId: "doc_1",
|
||||
newId: "doc_2",
|
||||
title: "复制页面",
|
||||
document: {
|
||||
id: "doc_2",
|
||||
workspace_id: "ws_1",
|
||||
title: "复制页面",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-24T00:00:00Z",
|
||||
updated_at: "2026-04-24T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1302,22 +1495,22 @@ describe("/api/tree/commands route", () => {
|
||||
);
|
||||
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面");
|
||||
expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2");
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.subtree.copy",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "replace_documents",
|
||||
documents: [],
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.subtree.copy",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
items: [expect.objectContaining({ newId: "doc_2" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("restore action 走 tree.node.restore,并附带 replace_documents delta", async () => {
|
||||
it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => {
|
||||
const client = {
|
||||
mutation: vi.fn(),
|
||||
query: vi.fn(async () => ({
|
||||
@@ -1350,10 +1543,32 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "document_result",
|
||||
args: { documentField: "document" },
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.restored",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||
ok: true,
|
||||
document: {
|
||||
id: "doc_restore_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "恢复页面",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-23T00:00:00Z",
|
||||
updated_at: "2026-04-24T00:00:00Z",
|
||||
},
|
||||
updated_at: "2026-04-24T00:00:00Z",
|
||||
});
|
||||
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
||||
@@ -1412,16 +1627,16 @@ describe("/api/tree/commands route", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "tree.node.restore",
|
||||
}),
|
||||
commandPayload: expect.objectContaining({
|
||||
streamDelta: {
|
||||
op: "replace_documents",
|
||||
documents: [],
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "tree.node.restore",
|
||||
}),
|
||||
result: expect.objectContaining({
|
||||
document: expect.objectContaining({ id: "doc_restore_1" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1476,7 +1691,17 @@ describe("/api/tree/commands route", () => {
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {},
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "remove_document",
|
||||
args: { documentId: "doc_1" },
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.archived",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed"));
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
@@ -57,6 +57,26 @@ type TreeCommandPayload = {
|
||||
items?: TreeCopyItem[] | null;
|
||||
};
|
||||
|
||||
type TreeDeltaDocument = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number | null;
|
||||
is_starred: boolean | null;
|
||||
access_scope: "private" | "shared" | "public";
|
||||
is_template: boolean;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
type TreeMutationResult<TResult> = {
|
||||
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
||||
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
||||
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
||||
result: TResult;
|
||||
};
|
||||
|
||||
function trimOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -76,37 +96,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
||||
if (!streamDelta) {
|
||||
return commandPayload;
|
||||
}
|
||||
if (isRecord(commandPayload)) {
|
||||
return {
|
||||
...commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
|
||||
async function recordTreeCommandSuccess(args: {
|
||||
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
||||
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
||||
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||
commandPayload?: unknown;
|
||||
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
||||
result: unknown;
|
||||
}) {
|
||||
try {
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
context: args.context,
|
||||
envelope: args.envelope,
|
||||
client: args.client,
|
||||
commandPayload: args.commandPayload,
|
||||
plan: args.plan,
|
||||
result: args.result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[tree.commands] bridge success artifacts skipped:", error);
|
||||
console.warn("[tree.commands] Rust bridge success artifacts skipped:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,24 +142,6 @@ async function loadTreeCommandSidebarSnapshot(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTreeCommandSnapshotDelta(
|
||||
sidebarSnapshot: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (!isRecord(sidebarSnapshot)) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(sidebarSnapshot.documents)) {
|
||||
return {
|
||||
op: "replace_documents",
|
||||
documents: sidebarSnapshot.documents,
|
||||
};
|
||||
}
|
||||
return {
|
||||
op: "replace_sidebar",
|
||||
sidebar: sidebarSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTreeMovePreflightDataFromSidebarSnapshot(
|
||||
sidebarSnapshot: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
@@ -203,6 +191,7 @@ async function resolveTreeMutationResult<TResult>(args: {
|
||||
return {
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -275,8 +264,9 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
||||
|
||||
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
||||
const title = normalizeTitle(payload.title);
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
id: string;
|
||||
document?: TreeDeltaDocument | null;
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number | null;
|
||||
@@ -300,27 +290,15 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: documentId,
|
||||
client,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
|
||||
await ensureDocumentScaffold(result.id, result.title ?? title);
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(envelope.payload, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: result.id,
|
||||
workspace_id: result.workspace_id,
|
||||
title: result.title ?? title,
|
||||
parent_id: result.parent_id ?? parentId,
|
||||
sort_order: result.sort_order ?? 0,
|
||||
access_scope: result.access_scope,
|
||||
is_starred: false,
|
||||
is_template: result.is_template,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
},
|
||||
}),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -356,7 +334,7 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
|
||||
workspaceId,
|
||||
});
|
||||
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
ok?: boolean;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
@@ -375,19 +353,13 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: documentId,
|
||||
client,
|
||||
});
|
||||
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||
client,
|
||||
auth,
|
||||
workspaceId,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(
|
||||
envelope.payload,
|
||||
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
|
||||
),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -426,8 +398,9 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
|
||||
|
||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||
const title = assertTitle(payload.title ?? null);
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
ok?: boolean;
|
||||
document?: TreeDeltaDocument | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
request,
|
||||
@@ -441,18 +414,13 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: documentId,
|
||||
client,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(envelope.payload, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: documentId,
|
||||
title,
|
||||
updated_at: result?.updated_at ?? null,
|
||||
},
|
||||
}),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -478,8 +446,9 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
||||
}
|
||||
|
||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
ok?: boolean;
|
||||
document?: TreeDeltaDocument | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
request,
|
||||
@@ -492,14 +461,13 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: documentId,
|
||||
client,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(envelope.payload, {
|
||||
op: "remove_document",
|
||||
documentId,
|
||||
}),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -516,7 +484,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
||||
}
|
||||
|
||||
async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
@@ -524,8 +492,9 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
||||
}
|
||||
|
||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
ok?: boolean;
|
||||
document?: TreeDeltaDocument | null;
|
||||
updated_at?: string | null;
|
||||
}>({
|
||||
request,
|
||||
@@ -538,19 +507,13 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: documentId,
|
||||
client,
|
||||
});
|
||||
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||
client,
|
||||
auth,
|
||||
workspaceId,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(
|
||||
envelope.payload,
|
||||
buildTreeCommandSnapshotDelta(sidebarSnapshot),
|
||||
),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -575,7 +538,7 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
|
||||
}
|
||||
|
||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
ok?: boolean;
|
||||
purged?: boolean;
|
||||
purged_at?: string | null;
|
||||
@@ -589,14 +552,13 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: documentId,
|
||||
client,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(envelope.payload, {
|
||||
op: "remove_document",
|
||||
documentId,
|
||||
}),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -659,7 +621,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
||||
trimOrNull(sourceDoc.workspace_id) ??
|
||||
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
}>({
|
||||
@@ -688,13 +650,13 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: targetId,
|
||||
client,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(envelope.payload, {
|
||||
op: "noop",
|
||||
}),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -712,7 +674,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
||||
}
|
||||
|
||||
async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const normalizedItems = (payload.items ?? [])
|
||||
.filter((item) => item?.documentId)
|
||||
.map((item) => ({
|
||||
@@ -747,11 +709,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
||||
const mutation = await resolveTreeMutationResult<{
|
||||
items: Array<{
|
||||
oldId: string;
|
||||
newId: string;
|
||||
title?: string | null;
|
||||
document?: TreeDeltaDocument | null;
|
||||
}>;
|
||||
}>({
|
||||
request,
|
||||
@@ -765,6 +728,7 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
||||
pageId: targetParentId,
|
||||
client,
|
||||
});
|
||||
const { context, envelope, result } = mutation;
|
||||
|
||||
await Promise.all(
|
||||
(result.items ?? []).map(async (item) => {
|
||||
@@ -772,19 +736,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
||||
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
||||
}),
|
||||
);
|
||||
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
||||
client,
|
||||
auth,
|
||||
workspaceId,
|
||||
});
|
||||
await recordTreeCommandSuccess({
|
||||
context,
|
||||
envelope,
|
||||
client,
|
||||
commandPayload: attachStreamDelta(
|
||||
envelope.payload,
|
||||
buildTreeCommandSnapshotDelta(sidebarSnapshot),
|
||||
),
|
||||
plan: mutation.plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{
|
||||
status:
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? ((error as { status: number }).status ?? 500)
|
||||
: 500,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||
mockDocumentBridgeErrorResponse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||
mockResolveRustBridgeCommandPlan(...args),
|
||||
}));
|
||||
|
||||
describe("/api/tree/filetree/delete-preflight route", () => {
|
||||
beforeEach(() => {
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockBuildDocumentBridgeContext.mockReset();
|
||||
mockBuildDocumentCommandEnvelope.mockReset();
|
||||
mockResolveRustBridgeCommandPlan.mockReset();
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
});
|
||||
|
||||
it("应通过 Rust tree.filetree.delete.preflight 返回规范化 delete plan", async () => {
|
||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||
requestId: "req_filetree_delete_1",
|
||||
traceId: "trace_filetree_delete_1",
|
||||
workspaceId: "ws_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "tree.filetree.delete.preflight",
|
||||
commandId: "cmd_filetree_delete_1",
|
||||
functionName: "tree:fileTreeDeletePreflight",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_filetree_delete_1",
|
||||
traceId: "trace_filetree_delete_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
fileTreeDeletePlan: {
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
docIds: ["doc_1"],
|
||||
assetIds: ["asset_1"],
|
||||
assetDocumentIds: ["doc_other"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://127.0.0.1:3000/api/tree/filetree/delete-preflight", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_1",
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
rows: [],
|
||||
documentParents: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
requestId: "req_filetree_delete_1",
|
||||
traceId: "trace_filetree_delete_1",
|
||||
plan: {
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
docIds: ["doc_1"],
|
||||
assetIds: ["asset_1"],
|
||||
assetDocumentIds: ["doc_other"],
|
||||
},
|
||||
});
|
||||
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "tree.filetree.delete.preflight",
|
||||
payload: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
}),
|
||||
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
|
||||
type FileTreeDeletePreflightPayload = {
|
||||
workspaceId?: string | null;
|
||||
rowIds?: string[];
|
||||
rows?: unknown[];
|
||||
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
|
||||
};
|
||||
|
||||
function trimOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function readFileTreeDeletePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
|
||||
const value = plan.argsJson.fileTreeDeletePlan;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Rust runtime 未返回 fileTreeDeletePlan");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await request.json()) as FileTreeDeletePreflightPayload;
|
||||
const workspaceId = trimOrNull(payload.workspaceId);
|
||||
const normalizedPayload = {
|
||||
workspaceId,
|
||||
rowIds: normalizeStringArray(payload.rowIds),
|
||||
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||
documentParents: Array.isArray(payload.documentParents)
|
||||
? payload.documentParents.map((item) => ({
|
||||
documentId: trimOrNull(item?.documentId),
|
||||
parentId: trimOrNull(item?.parentId),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "tree.filetree.delete.preflight",
|
||||
payload: normalizedPayload,
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
},
|
||||
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
plan: readFileTreeDeletePlan(plan),
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{
|
||||
status:
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? ((error as { status: number }).status ?? 500)
|
||||
: 500,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||
mockDocumentBridgeErrorResponse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||
mockResolveRustBridgeCommandPlan(...args),
|
||||
}));
|
||||
|
||||
describe("/api/tree/filetree/drop-preflight route", () => {
|
||||
beforeEach(() => {
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockBuildDocumentBridgeContext.mockReset();
|
||||
mockBuildDocumentCommandEnvelope.mockReset();
|
||||
mockResolveRustBridgeCommandPlan.mockReset();
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
});
|
||||
|
||||
it("应通过 Rust tree.filetree.drop.preflight 返回规范化 drop plan", async () => {
|
||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||
requestId: "req_filetree_drop_1",
|
||||
traceId: "trace_filetree_drop_1",
|
||||
workspaceId: "ws_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "tree.filetree.drop.preflight",
|
||||
commandId: "cmd_filetree_drop_1",
|
||||
functionName: "tree:fileTreeDropPreflight",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_filetree_drop_1",
|
||||
traceId: "trace_filetree_drop_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
fileTreeDropPlan: {
|
||||
copy: false,
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: null,
|
||||
targetSubPath: null,
|
||||
rowIds: ["doc:doc_1"],
|
||||
docIds: ["doc_1"],
|
||||
topLevelDocIds: ["doc_1"],
|
||||
copyableAssetIds: [],
|
||||
sourceAssetDocumentIds: [],
|
||||
documentTransferPlan: {
|
||||
action: "move",
|
||||
targetParentId: "doc_target",
|
||||
documentIds: ["doc_1"],
|
||||
topLevelDocumentIds: ["doc_1"],
|
||||
copyItems: [{ documentId: "doc_1", recursive: true }],
|
||||
},
|
||||
resourceTransferPlan: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://127.0.0.1:3000/api/tree/filetree/drop-preflight", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_1",
|
||||
copy: false,
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: null,
|
||||
focusedRowId: null,
|
||||
activeDocumentId: null,
|
||||
rowIds: ["doc:doc_1"],
|
||||
rows: [],
|
||||
documentParents: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
requestId: "req_filetree_drop_1",
|
||||
traceId: "trace_filetree_drop_1",
|
||||
plan: {
|
||||
copy: false,
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: null,
|
||||
targetSubPath: null,
|
||||
rowIds: ["doc:doc_1"],
|
||||
docIds: ["doc_1"],
|
||||
topLevelDocIds: ["doc_1"],
|
||||
copyableAssetIds: [],
|
||||
sourceAssetDocumentIds: [],
|
||||
documentTransferPlan: {
|
||||
action: "move",
|
||||
targetParentId: "doc_target",
|
||||
documentIds: ["doc_1"],
|
||||
topLevelDocumentIds: ["doc_1"],
|
||||
copyItems: [{ documentId: "doc_1", recursive: true }],
|
||||
},
|
||||
resourceTransferPlan: null,
|
||||
},
|
||||
});
|
||||
expect(mockBuildDocumentBridgeContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
}),
|
||||
);
|
||||
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "tree.filetree.drop.preflight",
|
||||
payload: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
rowIds: ["doc:doc_1"],
|
||||
}),
|
||||
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
|
||||
type FileTreeDropPreflightPayload = {
|
||||
workspaceId?: string | null;
|
||||
copy?: boolean;
|
||||
targetDocumentId?: string | null;
|
||||
targetRowId?: string | null;
|
||||
focusedRowId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
rowIds?: string[];
|
||||
rows?: unknown[];
|
||||
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
|
||||
};
|
||||
|
||||
function trimOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function readFileTreeDropPlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
|
||||
const value = plan.argsJson.fileTreeDropPlan;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Rust runtime 未返回 fileTreeDropPlan");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await request.json()) as FileTreeDropPreflightPayload;
|
||||
const workspaceId = trimOrNull(payload.workspaceId);
|
||||
const normalizedPayload = {
|
||||
workspaceId,
|
||||
copy: Boolean(payload.copy),
|
||||
targetDocumentId: trimOrNull(payload.targetDocumentId),
|
||||
targetRowId: trimOrNull(payload.targetRowId),
|
||||
focusedRowId: trimOrNull(payload.focusedRowId),
|
||||
activeDocumentId: trimOrNull(payload.activeDocumentId),
|
||||
rowIds: normalizeStringArray(payload.rowIds),
|
||||
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||
documentParents: Array.isArray(payload.documentParents)
|
||||
? payload.documentParents.map((item) => ({
|
||||
documentId: trimOrNull(item?.documentId),
|
||||
parentId: trimOrNull(item?.parentId),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "tree.filetree.drop.preflight",
|
||||
payload: normalizedPayload,
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedPayload.targetDocumentId ?? undefined,
|
||||
},
|
||||
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
plan: readFileTreeDropPlan(plan),
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{
|
||||
status:
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? ((error as { status: number }).status ?? 500)
|
||||
: 500,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||
mockDocumentBridgeErrorResponse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||
mockResolveRustBridgeCommandPlan(...args),
|
||||
}));
|
||||
|
||||
describe("/api/tree/filetree/paste-preflight route", () => {
|
||||
beforeEach(() => {
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockBuildDocumentBridgeContext.mockReset();
|
||||
mockBuildDocumentCommandEnvelope.mockReset();
|
||||
mockResolveRustBridgeCommandPlan.mockReset();
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
});
|
||||
|
||||
it("应通过 Rust tree.filetree.paste.preflight 返回规范化 paste plan", async () => {
|
||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||
requestId: "req_filetree_paste_1",
|
||||
traceId: "trace_filetree_paste_1",
|
||||
workspaceId: "ws_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "tree.filetree.paste.preflight",
|
||||
commandId: "cmd_filetree_paste_1",
|
||||
functionName: "tree:fileTreePastePreflight",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_filetree_paste_1",
|
||||
traceId: "trace_filetree_paste_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
fileTreePastePlan: {
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
docItems: [{ documentId: "doc_1", recursive: false }],
|
||||
copyableAssetIds: ["asset_1"],
|
||||
resourceTransferPlan: {
|
||||
action: "copy",
|
||||
assetIds: ["asset_1"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://127.0.0.1:3000/api/tree/filetree/paste-preflight", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: null,
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocumentId: "doc_active",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
rows: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
requestId: "req_filetree_paste_1",
|
||||
traceId: "trace_filetree_paste_1",
|
||||
plan: {
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
docItems: [{ documentId: "doc_1", recursive: false }],
|
||||
copyableAssetIds: ["asset_1"],
|
||||
resourceTransferPlan: {
|
||||
action: "copy",
|
||||
assetIds: ["asset_1"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "tree.filetree.paste.preflight",
|
||||
payload: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
}),
|
||||
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
|
||||
type FileTreePastePreflightPayload = {
|
||||
workspaceId?: string | null;
|
||||
targetDocumentId?: string | null;
|
||||
focusedRowId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
rowIds?: string[];
|
||||
rows?: unknown[];
|
||||
};
|
||||
|
||||
function trimOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function readFileTreePastePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
|
||||
const value = plan.argsJson.fileTreePastePlan;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Rust runtime 未返回 fileTreePastePlan");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await request.json()) as FileTreePastePreflightPayload;
|
||||
const workspaceId = trimOrNull(payload.workspaceId);
|
||||
const normalizedPayload = {
|
||||
workspaceId,
|
||||
targetDocumentId: trimOrNull(payload.targetDocumentId),
|
||||
focusedRowId: trimOrNull(payload.focusedRowId),
|
||||
activeDocumentId: trimOrNull(payload.activeDocumentId),
|
||||
rowIds: normalizeStringArray(payload.rowIds),
|
||||
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||
};
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "tree.filetree.paste.preflight",
|
||||
payload: normalizedPayload,
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedPayload.targetDocumentId ?? undefined,
|
||||
},
|
||||
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
plan: readFileTreePastePlan(plan),
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn(() => true);
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{
|
||||
status:
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? ((error as { status: number }).status ?? 500)
|
||||
: 500,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||
mockDocumentBridgeErrorResponse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||
mockResolveRustBridgeCommandPlan(...args),
|
||||
}));
|
||||
|
||||
describe("/api/tree/filetree/upload-target-preflight route", () => {
|
||||
beforeEach(() => {
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockBuildDocumentBridgeContext.mockReset();
|
||||
mockBuildDocumentCommandEnvelope.mockReset();
|
||||
mockResolveRustBridgeCommandPlan.mockReset();
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
});
|
||||
|
||||
it("应通过 Rust tree.filetree.upload-target.preflight 返回规范化 upload target plan", async () => {
|
||||
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||
requestId: "req_filetree_upload_target_1",
|
||||
traceId: "trace_filetree_upload_target_1",
|
||||
workspaceId: "ws_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "tree.filetree.upload-target.preflight",
|
||||
commandId: "cmd_filetree_upload_target_1",
|
||||
functionName: "tree:fileTreeUploadTargetPreflight",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_filetree_upload_target_1",
|
||||
traceId: "trace_filetree_upload_target_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
fileTreeUploadTargetPlan: {
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(
|
||||
new Request("http://127.0.0.1:3000/api/tree/filetree/upload-target-preflight", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_fallback",
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset:asset_child_1",
|
||||
focusedRowId: null,
|
||||
activeDocumentId: "doc_active",
|
||||
rows: [],
|
||||
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
requestId: "req_filetree_upload_target_1",
|
||||
traceId: "trace_filetree_upload_target_1",
|
||||
plan: {
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
});
|
||||
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "tree.filetree.upload-target.preflight",
|
||||
payload: expect.objectContaining({
|
||||
workspaceId: "ws_fallback",
|
||||
targetRowId: "asset:asset_child_1",
|
||||
activeDocumentId: "doc_active",
|
||||
}),
|
||||
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
|
||||
type FileTreeUploadTargetPreflightPayload = {
|
||||
workspaceId?: string | null;
|
||||
targetDocumentId?: string | null;
|
||||
targetRowId?: string | null;
|
||||
focusedRowId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
rows?: unknown[];
|
||||
documentWorkspaces?: Array<{ documentId?: string | null; workspaceId?: string | null }>;
|
||||
};
|
||||
|
||||
function trimOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readFileTreeUploadTargetPlan(
|
||||
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>,
|
||||
) {
|
||||
const value = plan.argsJson.fileTreeUploadTargetPlan;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Rust runtime 未返回 fileTreeUploadTargetPlan");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await request.json()) as FileTreeUploadTargetPreflightPayload;
|
||||
const workspaceId = trimOrNull(payload.workspaceId);
|
||||
const normalizedPayload = {
|
||||
workspaceId,
|
||||
targetDocumentId: trimOrNull(payload.targetDocumentId),
|
||||
targetRowId: trimOrNull(payload.targetRowId),
|
||||
focusedRowId: trimOrNull(payload.focusedRowId),
|
||||
activeDocumentId: trimOrNull(payload.activeDocumentId),
|
||||
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||
documentWorkspaces: Array.isArray(payload.documentWorkspaces)
|
||||
? payload.documentWorkspaces.map((item) => ({
|
||||
documentId: trimOrNull(item?.documentId),
|
||||
workspaceId: trimOrNull(item?.workspaceId),
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
validateOnly: true,
|
||||
dryRun: true,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "tree.filetree.upload-target.preflight",
|
||||
payload: normalizedPayload,
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedPayload.targetDocumentId ?? undefined,
|
||||
},
|
||||
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
|
||||
refs: ["file-tree-shell"],
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
plan: readFileTreeUploadTargetPlan(plan),
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockIsConvexEnabled = vi.fn();
|
||||
const mockGetAuthedConvexClient = vi.fn();
|
||||
const mockBuildDocumentBridgeContext = vi.fn();
|
||||
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||
const mockResolveRustBridgeQueryPlan = vi.fn();
|
||||
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
||||
const mockResolveKernelFileTreeProjection = vi.fn();
|
||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||
Response.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
|
||||
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
|
||||
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
executeRustBridgeQueryTransport: (...args: unknown[]) =>
|
||||
mockExecuteRustBridgeQueryTransport(...args),
|
||||
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/kernel-file-tree", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/server/kernel-file-tree")>(
|
||||
"@/lib/server/kernel-file-tree",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveKernelFileTreeProjection: (...args: unknown[]) =>
|
||||
mockResolveKernelFileTreeProjection(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("/api/tree/projections/file route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user_1",
|
||||
},
|
||||
client: {
|
||||
query: vi.fn(),
|
||||
mutation: vi.fn(),
|
||||
},
|
||||
});
|
||||
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
});
|
||||
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
|
||||
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
|
||||
functionName: "sidebar:datasetList",
|
||||
argsJson: {
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
});
|
||||
mockExecuteRustBridgeQueryTransport.mockReset().mockResolvedValue({
|
||||
active_workspace_id: "ws_1",
|
||||
documents: [],
|
||||
media_assets: [],
|
||||
mindmap_assets: [],
|
||||
table_assets: [],
|
||||
mindmap_asset_children: {},
|
||||
});
|
||||
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
|
||||
projectionId: "kernel_projection:file_tree:page_root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: "page_root",
|
||||
items: [
|
||||
{
|
||||
rowId: "doc:page_root",
|
||||
nodeId: "page_root",
|
||||
projectionKind: "file_tree",
|
||||
rowKind: "document",
|
||||
},
|
||||
{
|
||||
rowId: "asset:table_1",
|
||||
nodeId: "asset:table_1",
|
||||
projectionKind: "file_tree",
|
||||
rowKind: "asset",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
mockDocumentBridgeErrorResponse.mockClear();
|
||||
});
|
||||
|
||||
it("通过 3000 同源 route 返回 Rust file_tree 搜索 projection", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
|
||||
{ method: "GET" },
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
rootNodeId: "page_root",
|
||||
depth: 3,
|
||||
query: "预算",
|
||||
maxResults: 12,
|
||||
}),
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
projection: "file_tree",
|
||||
rootNodeId: "page_root",
|
||||
},
|
||||
});
|
||||
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
|
||||
"doc:page_root",
|
||||
"asset:table_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Convex 未启用时返回 501", async () => {
|
||||
mockIsConvexEnabled.mockReturnValue(false);
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(
|
||||
new Request("http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(501);
|
||||
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContext,
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||
import { resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function readNumberParam(url: URL, name: string): number | null {
|
||||
const raw = url.searchParams.get(name);
|
||||
if (!raw?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const context = await buildDocumentBridgeContext({
|
||||
request,
|
||||
workspaceId,
|
||||
});
|
||||
const envelope = buildDocumentQueryEnvelope({
|
||||
name: "sidebar.dataset.list",
|
||||
payload: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
const plan = await resolveRustBridgeQueryPlan({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const projection = await resolveKernelFileTreeProjection({
|
||||
client,
|
||||
request,
|
||||
workspaceId,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
dataset,
|
||||
rootNodeId: url.searchParams.get("rootNodeId")?.trim() || null,
|
||||
depth: readNumberParam(url, "depth"),
|
||||
query: url.searchParams.get("query")?.trim() || null,
|
||||
maxResults: readNumberParam(url, "maxResults"),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
requestId: context.requestId,
|
||||
traceId: context.traceId,
|
||||
result: projection,
|
||||
});
|
||||
} catch (error) {
|
||||
return documentBridgeErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,23 @@ describe("/api/tree/shell route", () => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
|
||||
it("未显式 debug 时不应再代理 3104 tree shell", async () => {
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({
|
||||
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
|
||||
});
|
||||
});
|
||||
|
||||
it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
|
||||
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
|
||||
mockBuildForwardHeaders.mockResolvedValue(
|
||||
new Headers({
|
||||
@@ -43,7 +59,7 @@ describe("/api/tree/shell route", () => {
|
||||
const { GET } = await import("./route");
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
||||
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -54,7 +70,7 @@ describe("/api/tree/shell route", () => {
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
||||
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.any(Headers),
|
||||
|
||||
@@ -24,6 +24,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const debugEnabled =
|
||||
requestUrl.searchParams.get("debug") === "1" ||
|
||||
requestUrl.searchParams.get("internal") === "1" ||
|
||||
process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1";
|
||||
if (!debugEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
|
||||
targetUrl.search = requestUrl.search;
|
||||
|
||||
Reference in New Issue
Block a user