feat: 收口本地文件夹入口并推进 page aggregate rust-first

- 为 Rust Web 主入口补齐本地文件夹/云空间切换、最近目录与路径回填体验\n- 对齐 local markdown media 与 inline marks 的 Rust shell / TipTap converter 语义\n- 让 documents/page 优先消费 Rust page aggregate snapshot,并保留 TS fallback\n- 补强 tree live、local markdown 与主入口 smoke,并同步设计稿状态
This commit is contained in:
lix-2026
2026-05-09 19:05:06 +08:00
parent 71146de5f0
commit d148e1ccc2
20 changed files with 1635 additions and 374 deletions
@@ -0,0 +1,266 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => 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/convex/route", () => ({
getAuthedConvexClient: vi.fn(async () => ({
auth: { userId: "user_1" },
client: {
query: vi.fn(async () => ({
id: "doc_1",
workspace_id: "ws_1",
title: "Next fallback 页面",
updated_at: null,
can_edit: true,
})),
},
})),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeQueryPlan: vi.fn(async () => ({
kind: "query",
queryName: "documents.content.get",
functionName: "documents:getContent",
workspaceId: "ws_1",
requestId: "req_next",
traceId: "trace_next",
actorId: "user_1",
payloadJson: "{}",
argsJson: { id: "doc_1" },
})),
executeRustBridgeQueryTransport: vi.fn(async () => ({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 1,
conflict_detection_key: "doc_1:1",
pageSubtree: null,
})),
}));
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: vi.fn(async () => "http://127.0.0.1:3104"),
}));
import { GET } from "@/app/api/documents/page/route";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
function rustPageAggregateSnapshot() {
return {
schema: "mnote.page_aggregate.v1" as const,
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "Rust 聚合页面",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: [], revision: 7, conflictDetectionKey: "doc_1:7" },
tree: { pageSubtree: null },
stats: { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 },
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("documents/page route", () => {
it("优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: rustPageAggregateSnapshot(),
requestId: "req_rust",
traceId: "trace_rust",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_route",
"x-trace-id": "trace_route",
"x-session-id": "sess_route",
"x-source-channel": "next-route",
"x-source-client": "vitest",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; identity: { documentId: string }; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(resolveMnoteWebInternalUrl).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/page-aggregate/doc_1?workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
signal: expect.any(AbortSignal),
headers: expect.any(Headers),
}),
);
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("authorization")).toBe("Bearer route-token");
expect(fetchHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(fetchHeaders.get("x-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-mnote-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-mnote-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-mnote-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-source-client")).toBe("vitest");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("vitest");
expect(getAuthedConvexClient).not.toHaveBeenCalled();
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.page.head.title).toBe("Rust 聚合页面");
expect(payload.meta).toEqual({
requestId: "req_rust",
traceId: "trace_rust",
queryName: "documents.page.get",
});
});
it("Rust snapshot 返回畸形 projection 时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: {
schema: "mnote.page_aggregate.v1",
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: { title: "错误页面", updatedAt: null },
layout: {},
body: { content: [], revision: "bad_revision", conflictDetectionKey: 7 },
tree: { pageSubtree: null },
stats: null,
},
requestId: "req_bad",
traceId: "trace_bad",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_next",
"x-trace-id": "trace_next",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string }; body: { revision: number | null } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.page.body.revision).toBe(1);
expect(payload.meta).toEqual({
requestId: "req_next",
traceId: "trace_next",
queryName: "documents.page.get",
});
});
it("Rust internal base 不可信时直接 fallback,且不会外发凭据", async () => {
vi.mocked(resolveMnoteWebInternalUrl).mockResolvedValueOnce("https://example.com");
const fetchMock = vi.spyOn(globalThis, "fetch");
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_untrusted",
"x-trace-id": "trace_untrusted",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(fetchMock).not.toHaveBeenCalled();
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_untrusted",
traceId: "trace_untrusted",
queryName: "documents.page.get",
});
});
it("Rust snapshot fetch 抛错时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_timeout",
"x-trace-id": "trace_timeout",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_timeout",
traceId: "trace_timeout",
queryName: "documents.page.get",
});
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
@@ -28,10 +28,8 @@ vi.mock("@/lib/api-utils", () => ({
vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
}));
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
@@ -72,18 +70,11 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postDelete } from "@/app/api/documents/delete/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postRestore } from "@/app/api/documents/restore/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { POST as postCopyTree } from "@/app/api/documents/copy-tree/route";
import { POST as postCreate } from "@/app/api/documents/create/route";
import { POST as postMove } from "@/app/api/documents/move/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
@@ -93,107 +84,12 @@ import {
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("documents route adapters", () => {
it("create route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_create_1",
traceId: "trace_tree_create_1",
result: {
action: "create",
workspaceId: "ws_1",
documentId: "doc_new",
parentId: null,
title: "无标题",
sortOrder: 0,
updatedAt: "2026-04-23T00:00:00Z",
execution: {
access_scope: "private",
is_template: false,
created_at: "2026-04-23T00:00:00Z",
updated_at: "2026-04-23T00:00:00Z",
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCreate(new Request("http://localhost/api/documents/create", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parentId: null }),
}));
const payload = await response.json() as {
id: string;
workspace_id: string;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "create",
parentId: null,
}),
}),
);
expect(payload.id).toBe("doc_new");
expect(payload.workspace_id).toBe("ws_1");
expect(payload.meta.commandName).toBe("tree.node.create");
});
it("move route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_move_1",
traceId: "trace_tree_move_1",
result: {
action: "move",
workspaceId: "ws_1",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postMove(new Request("http://localhost/api/documents/move", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", parentId: "parent_1", position: 2.7, workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "move",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
workspaceId: "ws_1",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.subtree.move");
});
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
@@ -260,117 +156,6 @@ describe("documents route adapters", () => {
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
});
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_archive_1",
traceId: "trace_tree_archive_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "archive",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.archive");
});
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_restore_1",
traceId: "trace_tree_restore_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
}));
const payload = await response.json() as {
success: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "restore",
documentId: "doc_1",
workspaceId: "ws_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.restore");
});
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_embed_1",
traceId: "trace_tree_embed_1",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
}));
const payload = await response.json() as {
ok: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "embed",
sourceId: "doc_1",
targetId: "doc_2",
}),
}),
);
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.embed");
});
it("template route delegates to unified adapter", async () => {
await postTemplate(new Request("http://localhost/api/documents/template", {
method: "POST",
@@ -387,87 +172,6 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
});
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_purge_1",
traceId: "trace_tree_purge_1",
result: {
purged: true,
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ documentId: "doc_1" }),
}));
const payload = await response.json() as {
success: boolean;
purged: boolean;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "purge",
documentId: "doc_1",
}),
}),
);
expect(payload.success).toBe(true);
expect(payload.purged).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.purge");
});
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_copy_1",
traceId: "trace_tree_copy_1",
result: {
items: [{ oldId: "doc_1", newId: "doc_2" }],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
}));
const payload = await response.json() as {
items: Array<{ oldId: string; newId: string }>;
meta: { commandName: string };
};
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost/api/tree/commands",
expect.objectContaining({
method: "POST",
headers: expect.any(Headers),
body: JSON.stringify({
action: "copy",
targetParentId: null,
items: [{ documentId: "doc_1", recursive: true }],
}),
}),
);
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
expect(payload.meta.commandName).toBe("tree.subtree.copy");
});
it("page route delegates to unified aggregate loader", async () => {
const response = await getPage(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),