feat: 收口 tree-first graph 主链与前端测试修复

This commit is contained in:
lix-2026
2026-04-18 05:43:49 +08:00
parent d8de820d93
commit d3876e56eb
33 changed files with 2421 additions and 345 deletions
@@ -243,6 +243,61 @@ describe("documents bridge helpers", () => {
});
});
it("buildDocumentBridgeMutationRequest builds documents.embed runtime request", () => {
const payload = {
...buildDocumentSavePayload({
documentId: "doc_2",
workspaceId: "ws_1",
revision: 3,
content: [{ id: "block_2", type: "pageReference" }],
conflictDetectionKey: "conflict_3",
blockCount: 1,
}),
sourceDocumentId: "doc_1",
targetDocumentId: "doc_2",
anchorBlockId: "anchor_1",
};
const envelope = buildDocumentCommandEnvelope({
name: "documents.embed",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_2" },
});
const request = buildDocumentBridgeMutationRequest({
context: mockContext,
envelope,
mapConvexArgs: (nextPayload) => ({
id: nextPayload.documentId,
content: nextPayload.content,
expectedRevision: nextPayload.revision,
conflictDetectionKey: nextPayload.conflictDetectionKey,
sourceDocumentId: nextPayload.sourceDocumentId,
targetDocumentId: nextPayload.targetDocumentId,
anchorBlockId: nextPayload.anchorBlockId,
}),
});
expect(request.functionName).toBe("documents:updateContent");
expect(request.workspaceId).toBe("ws_1");
expect(request.args).toEqual({
id: "doc_2",
content: [{ id: "block_2", type: "pageReference" }],
expectedRevision: 3,
conflictDetectionKey: "conflict_3",
sourceDocumentId: "doc_1",
targetDocumentId: "doc_2",
anchorBlockId: "anchor_1",
});
expect(JSON.parse(request.payloadJson)).toMatchObject({
kind: "command",
name: "documents.embed",
workspace_id: "ws_1",
request_id: "req_1",
trace_id: "trace_1",
});
});
it("buildDocumentBridgeMutationRequest builds media asset writeback runtime request", () => {
const envelope = buildDocumentCommandEnvelope({
name: "media.assets.replace_storage",
@@ -126,6 +126,7 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.template": "documents:setTemplate",
"documents.emptyTrashByWorkspace": "documents:emptyTrashByWorkspace",
"documents.purge": "documents:purge",
"documents.embed": "documents:updateContent",
"blocks.patch": "documents:updateContent",
"blocks.move": "documents:updateContent",
"blocks.embed": "documents:updateContent",
@@ -20,7 +20,6 @@ import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
@@ -79,6 +78,19 @@ export type DocumentPurgePayload = {
documentId: string;
};
export type DocumentEmbedPayload = {
documentId: string;
workspaceId: string | null;
revision: number | null;
content: Json;
conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
sourceDocumentId: string;
targetDocumentId: string;
anchorBlockId: string | null;
};
export type PageCommandExecutionResult<TResult> = {
requestId: string;
traceId: string;
@@ -287,23 +299,28 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
const context = await buildRuntimeContext(request, workspaceId);
const savePayload = buildDocumentSavePayload({
documentId: normalizedTargetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: payload,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
});
const embedPayload: DocumentEmbedPayload = {
...buildDocumentSavePayload({
documentId: normalizedTargetId,
workspaceId,
revision:
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
? targetContent.revision
: null,
content: payload,
conflictDetectionKey:
typeof targetContent.conflict_detection_key === "string"
? targetContent.conflict_detection_key
: null,
blockCount: nextBlocks.length,
}),
sourceDocumentId: normalizedSourceId,
targetDocumentId: normalizedTargetId,
anchorBlockId: anchorId,
};
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
payload: savePayload,
name: "documents.embed",
payload: embedPayload,
context,
target: {
workspaceId,
@@ -311,9 +328,13 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
},
});
const result = await executeSaveBridgeCommand({
const result = await executePageLifecycleBridgeCommand<DocumentEmbedPayload, {
revision?: number | null;
conflict_detection_key?: string | null;
}>({
context,
envelope,
client,
});
return NextResponse.json({
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import { buildParentById } from "@/lib/file-tree/dnd";
vi.mock("next/server", () => ({
NextResponse: {
json: (body: unknown, init?: { status?: number }) => ({
body,
status: init?.status ?? 200,
}),
},
}), { virtual: true });
vi.mock("@/lib/convex/api", () => ({
api: {
documents: { getMeta: "documents:getMeta" },
workspaces: { ensureDefaultWorkspace: "workspaces:ensureDefaultWorkspace" },
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContextWithActor: vi.fn(),
buildDocumentCommandEnvelope: vi.fn(),
documentBridgeErrorResponse: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeMutationTransport: vi.fn(),
resolveRustBridgeCommandPlan: vi.fn(),
}));
vi.mock("@/lib/server/local-paths", () => ({
getDocumentsBaseDir: () => "/tmp/mnote-vitest-documents",
}));
const {
normalizeDocumentCopyTreePayload,
normalizeDocumentMovePayload,
resolveSubtreeMoveLegality,
} = await import("./page-lifecycle-command-adapter");
describe("page-lifecycle-command-adapter", () => {
it("归一化 move payload 的 targetParentId 与 sortOrder", () => {
expect(
normalizeDocumentMovePayload({
documentId: " doc_1 ",
parentId: " parent_1 ",
position: 2.8,
}),
).toEqual({
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
});
expect(
normalizeDocumentMovePayload({
documentId: "doc_1",
parentId: " ",
position: Number.NaN,
}),
).toEqual({
documentId: "doc_1",
parentId: null,
sortOrder: 0,
});
});
it("归一化 copy_tree payload 并去重 source ids", () => {
expect(
normalizeDocumentCopyTreePayload({
targetParentId: " parent_1 ",
items: [
{ documentId: " doc_a ", recursive: true },
{ documentId: "doc_b", recursive: false },
{ documentId: "doc_a", recursive: false },
{ documentId: " ", recursive: true },
],
}),
).toEqual({
targetParentId: "parent_1",
items: [
{ documentId: "doc_a", recursive: true },
{ documentId: "doc_b", recursive: false },
{ documentId: "doc_a", recursive: false },
],
sourceIds: ["doc_a", "doc_b"],
});
});
it("在 subtree move legality 中先收敛顶层 source,再拦截自拖拽/拖入后代", () => {
const parentById = buildParentById([
{ id: "a", parentId: null },
{ id: "b", parentId: "a" },
{ id: "c", parentId: "b" },
{ id: "d", parentId: null },
]);
expect(
resolveSubtreeMoveLegality({
sourceDocIds: ["a", "b", "c", "d"],
targetParentId: "b",
parentById,
}),
).toEqual({
sourceDocIds: ["a", "d"],
targetParentId: "b",
isInvalid: true,
});
expect(
resolveSubtreeMoveLegality({
sourceDocIds: ["b", "c"],
targetParentId: "a",
parentById,
}),
).toEqual({
sourceDocIds: ["b"],
targetParentId: "a",
isInvalid: false,
});
});
});
@@ -1,5 +1,3 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
@@ -19,6 +17,7 @@ import {
} from "@/lib/documents/bridge-log";
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import { filterTopLevelDocIds, isInvalidDocDrop } from "@/lib/file-tree/dnd";
type CreatePayload = {
parentId?: string | null;
@@ -52,8 +51,34 @@ type CopyTreePayload = {
targetParentId?: string | null;
};
export type NormalizedDocumentMovePayload = {
documentId: string | null;
parentId: string | null;
sortOrder: number;
};
export type NormalizedCopyTreeItem = {
documentId: string;
recursive: boolean;
};
export type NormalizedDocumentCopyTreePayload = {
targetParentId: string | null;
items: NormalizedCopyTreeItem[];
sourceIds: string[];
};
const documentsBaseDir = getDocumentsBaseDir();
function assertServerEnvironment() {
if (typeof process !== "undefined" && process.env.VITEST === "true") {
return;
}
if (typeof window !== "undefined") {
throw new Error("page-lifecycle-command-adapter 仅允许在服务端执行");
}
}
function trimOrNull(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -65,6 +90,52 @@ function normalizeTitle(title: string | null): string {
return safe && safe.length > 0 ? safe : "无标题";
}
export function normalizeDocumentMovePayload(payload: MovePayload): NormalizedDocumentMovePayload {
return {
documentId: trimOrNull(payload.documentId),
parentId: trimOrNull(payload.parentId),
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
};
}
export function normalizeDocumentCopyTreePayload(
payload: CopyTreePayload,
): NormalizedDocumentCopyTreePayload {
const items = (payload.items ?? [])
.map((item) => ({
documentId: trimOrNull(item?.documentId),
recursive: Boolean(item?.recursive),
}))
.filter((item): item is NormalizedCopyTreeItem => Boolean(item.documentId))
.map((item) => ({
documentId: item.documentId,
recursive: item.recursive,
}));
return {
targetParentId: trimOrNull(payload.targetParentId),
items,
sourceIds: Array.from(new Set(items.map((item) => item.documentId))),
};
}
export function resolveSubtreeMoveLegality(input: {
sourceDocIds: string[];
targetParentId: string | null;
parentById: Map<string, string | null>;
}) {
const topLevelSourceDocIds = filterTopLevelDocIds(input.sourceDocIds, input.parentById);
return {
sourceDocIds: topLevelSourceDocIds,
targetParentId: input.targetParentId,
isInvalid: isInvalidDocDrop({
sourceDocIds: topLevelSourceDocIds,
targetParentId: input.targetParentId,
parentById: input.parentById,
}),
};
}
function safeRandomId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
@@ -138,6 +209,7 @@ async function handleLifecycleError(error: unknown) {
}
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try {
const payload = (await request.json()) as CreatePayload;
const { auth, client } = await getAuthedConvexClient();
@@ -185,7 +257,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
pageId: documentId,
},
});
const { context, plan } = await resolveCommandPlan({
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId,
authUserId: auth.userId,
@@ -213,7 +285,7 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
}
await recordBridgeCommandArtifacts({
context,
context: runtimeContext,
envelope,
client,
});
@@ -255,9 +327,11 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
}
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try {
const payload = (await request.json()) as MovePayload;
const documentId = trimOrNull(payload.documentId);
const normalizedMove = normalizeDocumentMovePayload(payload);
const documentId = normalizedMove.documentId;
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
@@ -273,8 +347,8 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
name: "documents.move",
payload: {
documentId,
parentId: trimOrNull(payload.parentId),
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
parentId: normalizedMove.parentId,
sortOrder: normalizedMove.sortOrder,
},
context,
target: {
@@ -329,6 +403,7 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
}
export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try {
const payload = (await request.json()) as DeletePayload;
const documentId = trimOrNull(payload.documentId);
@@ -399,6 +474,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
}
export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try {
const payload = (await request.json()) as RestorePayload;
const documentId = trimOrNull(payload.documentId);
@@ -473,6 +549,7 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
}
export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try {
const payload = (await request.json()) as DuplicatePayload;
const documentId = trimOrNull(payload.documentId);
@@ -569,16 +646,15 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
}
export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
try {
const payload = (await request.json()) as CopyTreePayload;
const { auth, client } = await getAuthedConvexClient();
const normalizedItems = (payload.items ?? []).filter((it) => trimOrNull(it?.documentId));
if (normalizedItems.length === 0) {
const normalizedCopyTree = normalizeDocumentCopyTreePayload(payload);
if (normalizedCopyTree.items.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = trimOrNull(payload.targetParentId);
const targetParentId = normalizedCopyTree.targetParentId;
let workspaceId: string | null = null;
if (targetParentId) {
@@ -589,7 +665,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
workspaceId = targetDoc.workspace_id;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => trimOrNull(it.documentId) as string)));
const sourceIds = normalizedCopyTree.sourceIds;
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
if (!firstMeta) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
@@ -607,10 +683,7 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
const outerEnvelope = buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
items: normalizedItems.map((item) => ({
documentId: trimOrNull(item.documentId) as string,
recursive: Boolean(item.recursive),
})),
items: normalizedCopyTree.items,
targetParentId,
},
context,
@@ -89,6 +89,7 @@ export type PageSubtreeProjection = {
};
const SNIPPET_MAX_LENGTH = 220;
const PAGE_SUBTREE_PROJECTION_ID = "kernel_projection:page_tree:document_subtree";
const pickFirstText = (...values: unknown[]) => {
for (const value of values) {
@@ -367,7 +368,7 @@ export function buildPageSubtreeProjection(input: {
}
return {
projectionId: `page_subtree:${rootNodeId}`,
projectionId: PAGE_SUBTREE_PROJECTION_ID,
projection: "page_tree",
rootNodeId,
rootNode,
@@ -43,6 +43,33 @@ type MoveDocumentInput = {
workspaceId?: string | null;
};
type DeleteDocumentInput = {
documentId: string;
workspaceId?: string | null;
};
type RestoreDocumentInput = {
documentId: string;
workspaceId?: string | null;
};
type PurgeDocumentInput = {
documentId: string;
};
type EmbedDocumentInput = {
sourceId: string;
targetId: string;
};
type CopyTreeCommandInput = {
targetParentId: string | null;
items: Array<{
documentId: string;
recursive: boolean;
}>;
};
type CreateChildDocumentInput = {
parentId: string | null;
title: string;
@@ -106,3 +133,73 @@ export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ o
"移动失败,请稍后再试",
);
}
export async function deleteDocumentCommand(
input: DeleteDocumentInput,
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
"/api/documents/delete",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
},
"删除失败,请稍后再试",
);
}
export async function restoreDocumentCommand(
input: RestoreDocumentInput,
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
"/api/documents/restore",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
},
"恢复失败,请稍后再试",
);
}
export async function purgeDocumentCommand(
input: PurgeDocumentInput,
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>(
"/api/documents/purge",
{
documentId: input.documentId,
},
"彻底删除失败,请稍后再试",
);
}
export async function embedDocumentCommand(
input: EmbedDocumentInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/embed",
{
sourceId: input.sourceId,
targetId: input.targetId,
},
"嵌入失败,请稍后再试",
);
}
export async function copyTreeCommand(
input: CopyTreeCommandInput,
): Promise<{
items: Array<{ oldId: string; newId: string }>;
meta?: DocumentCommandMeta;
}> {
return postDocumentCommand<{
items: Array<{ oldId: string; newId: string }>;
meta?: DocumentCommandMeta;
}>(
"/api/documents/copy-tree",
{
targetParentId: input.targetParentId,
items: input.items,
},
"复制页面失败,请稍后再试",
);
}