4-26 树rust-2
This commit is contained in:
@@ -23,6 +23,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client?: ConvexHttpClient;
|
||||
commandPayload?: unknown;
|
||||
status?: BridgeCommandLogStatus;
|
||||
eventStatus?: BridgeDomainEventStatus;
|
||||
error?: string | null;
|
||||
@@ -35,7 +36,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
const commandLogId = `clog_${input.envelope.commandId}`;
|
||||
const eventId = `evt_${input.envelope.commandId}`;
|
||||
const now = input.now ?? new Date().toISOString();
|
||||
const payload = input.envelope.payload as Record<string, unknown>;
|
||||
const payload = input.commandPayload ?? input.envelope.payload;
|
||||
const status = input.status ?? "succeeded";
|
||||
const eventStatus =
|
||||
input.eventStatus ??
|
||||
|
||||
@@ -70,6 +70,7 @@ export type CommandEnvelope<T> = {
|
||||
source: BridgeSource;
|
||||
target: BridgeTarget | null;
|
||||
payload: T;
|
||||
preflightData?: Record<string, unknown> | null;
|
||||
reason: string | null;
|
||||
refs: string[];
|
||||
dryRun: boolean;
|
||||
@@ -275,6 +276,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
|
||||
payload: T;
|
||||
context: BridgeContext;
|
||||
target?: BridgeTarget | null;
|
||||
preflightData?: Record<string, unknown> | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
}): CommandEnvelope<T> {
|
||||
@@ -286,6 +288,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
|
||||
source: input.context.source,
|
||||
target: input.target ?? null,
|
||||
payload: input.payload,
|
||||
preflightData: input.preflightData ?? null,
|
||||
reason: input.reason ?? null,
|
||||
refs: input.refs ?? [],
|
||||
dryRun: input.context.dryRun,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
compareDocumentCanonicalOrder,
|
||||
getCanonicalDocumentByBusinessId,
|
||||
getCanonicalParentDocumentId,
|
||||
pickCanonicalDocumentRecordsByBusinessId,
|
||||
pickCanonicalDocumentRecord,
|
||||
} from "../../../convex/_utils/documentRecord";
|
||||
|
||||
@@ -176,3 +177,37 @@ describe("canonical document helper", () => {
|
||||
expect(parentId).toBe("parent_alive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalDocumentRecordsByBusinessId", () => {
|
||||
it("同一 workspace 扫描结果里 business id 重复时应先折叠成 canonical 记录,供树命令写链复用", () => {
|
||||
const records = pickCanonicalDocumentRecordsByBusinessId([
|
||||
{
|
||||
_id: "doc_old",
|
||||
id: "doc_1",
|
||||
parent_id: "parent_old",
|
||||
deleted_at: null,
|
||||
created_at: "2026-04-14T00:00:00.000Z",
|
||||
updated_at: "2026-04-14T00:00:01.000Z",
|
||||
},
|
||||
{
|
||||
_id: "doc_new",
|
||||
id: "doc_1",
|
||||
parent_id: "parent_new",
|
||||
deleted_at: null,
|
||||
created_at: "2026-04-14T00:00:02.000Z",
|
||||
updated_at: "2026-04-14T00:00:03.000Z",
|
||||
},
|
||||
{
|
||||
_id: "doc_2",
|
||||
id: "doc_2",
|
||||
parent_id: null,
|
||||
deleted_at: null,
|
||||
created_at: "2026-04-14T00:00:04.000Z",
|
||||
updated_at: "2026-04-14T00:00:05.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records.map((record) => record._id)).toEqual(["doc_new", "doc_2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildParentById, isAncestorOf } from "../../../convex/_utils/documentTree";
|
||||
|
||||
describe("document tree helper", () => {
|
||||
it("构建父链映射时保留页面与父页面关系,供树命令 legality 复用", () => {
|
||||
const parentById = buildParentById([
|
||||
{ id: "root", parent_id: null },
|
||||
{ id: "child", parent_id: "root" },
|
||||
{ id: "leaf", parent_id: "child" },
|
||||
]);
|
||||
|
||||
expect(parentById.get("root")).toBeNull();
|
||||
expect(parentById.get("child")).toBe("root");
|
||||
expect(parentById.get("leaf")).toBe("child");
|
||||
});
|
||||
|
||||
it("祖先判断应能识别多级后代,避免把页面移动到自己的子树下面", () => {
|
||||
const parentById = buildParentById([
|
||||
{ id: "root", parent_id: null },
|
||||
{ id: "child", parent_id: "root" },
|
||||
{ id: "leaf", parent_id: "child" },
|
||||
]);
|
||||
|
||||
expect(isAncestorOf("root", "leaf", parentById)).toBe(true);
|
||||
expect(isAncestorOf("child", "leaf", parentById)).toBe(true);
|
||||
expect(isAncestorOf("leaf", "root", parentById)).toBe(false);
|
||||
expect(isAncestorOf("missing", "leaf", parentById)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildParentById } from "@/lib/file-tree/dnd";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
@@ -42,6 +55,7 @@ vi.mock("@/lib/server/local-paths", () => ({
|
||||
}));
|
||||
|
||||
const {
|
||||
handleDocumentMoveRequest,
|
||||
normalizeDocumentCopyTreePayload,
|
||||
normalizeDocumentMovePayload,
|
||||
resolveSubtreeMoveLegality,
|
||||
@@ -128,4 +142,244 @@ describe("page-lifecycle-command-adapter", () => {
|
||||
isInvalid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("documents.move 应把 movePreflight 透传给 Rust plan", async () => {
|
||||
const client = {
|
||||
query: vi.fn(async (name: string, args: { id: string }) => {
|
||||
if (name !== "documents:getMeta") {
|
||||
return null;
|
||||
}
|
||||
if (args.id === "doc_1") {
|
||||
return {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
};
|
||||
}
|
||||
if (args.id === "child_1") {
|
||||
return {
|
||||
id: "child_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: "doc_1",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
mutation: vi.fn(),
|
||||
};
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user_1",
|
||||
email: "dev@example.com",
|
||||
name: "开发用户",
|
||||
},
|
||||
client: client as never,
|
||||
});
|
||||
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_move_1",
|
||||
traceId: "trace_move_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
});
|
||||
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.move",
|
||||
commandId: "cmd_move_1",
|
||||
functionName: "documents:move",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_move_1",
|
||||
traceId: "trace_move_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
parentId: "child_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true } as never);
|
||||
|
||||
const response = await handleDocumentMoveRequest(
|
||||
new Request("http://127.0.0.1:3000/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
position: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: expect.objectContaining({
|
||||
requestId: "req_move_1",
|
||||
}),
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.move",
|
||||
preflightData: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
sortOrder: 0,
|
||||
movePreflight: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("documents.move 失败时记录的 failure artifact 仍应保持 move envelope", async () => {
|
||||
const client = {
|
||||
query: vi.fn(async (name: string, args: { id: string }) => {
|
||||
if (name !== "documents:getMeta") {
|
||||
return null;
|
||||
}
|
||||
if (args.id === "doc_1") {
|
||||
return {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
};
|
||||
}
|
||||
if (args.id === "child_1") {
|
||||
return {
|
||||
id: "child_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: "doc_1",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
mutation: vi.fn(),
|
||||
};
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user_1",
|
||||
email: "dev@example.com",
|
||||
name: "开发用户",
|
||||
},
|
||||
client: client as never,
|
||||
});
|
||||
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_move_2",
|
||||
traceId: "trace_move_2",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
});
|
||||
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockRejectedValue(new Error("move failed"));
|
||||
vi.mocked(documentBridgeErrorResponse).mockImplementation((error: unknown) => ({
|
||||
body: { error: error instanceof Error ? error.message : String(error) },
|
||||
status: 500,
|
||||
}) as never);
|
||||
|
||||
const response = await handleDocumentMoveRequest(
|
||||
new Request("http://127.0.0.1:3000/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
position: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(recordBridgeCommandFailureArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.move",
|
||||
preflightData: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
sortOrder: 0,
|
||||
movePreflight: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,18 @@ type MovePayload = {
|
||||
position?: number | null;
|
||||
};
|
||||
|
||||
type MovePreflightDocument = {
|
||||
id: string;
|
||||
workspaceId: string | null;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
type MovePreflightPayload = {
|
||||
sourceDocument: MovePreflightDocument;
|
||||
targetParentDocument: MovePreflightDocument | null;
|
||||
targetAncestorIds: string[];
|
||||
};
|
||||
|
||||
type DeletePayload = {
|
||||
documentId?: string | null;
|
||||
};
|
||||
@@ -136,6 +148,41 @@ export function resolveSubtreeMoveLegality(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMovePreflight(args: {
|
||||
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||
sourceDocument: MovePreflightDocument;
|
||||
targetParentId: string | null;
|
||||
}) : Promise<MovePreflightPayload> {
|
||||
let targetParentDocument: MovePreflightDocument | null = null;
|
||||
const targetAncestorIds: string[] = [];
|
||||
if (args.targetParentId) {
|
||||
const targetParentDoc = await args.client.query(api.documents.getMeta, { id: args.targetParentId });
|
||||
if (!targetParentDoc) {
|
||||
throw new Error("目标父页面不存在或无权限");
|
||||
}
|
||||
targetParentDocument = {
|
||||
id: targetParentDoc.id,
|
||||
workspaceId: trimOrNull(targetParentDoc.workspace_id),
|
||||
parentId: trimOrNull(targetParentDoc.parent_id),
|
||||
};
|
||||
let cursor = trimOrNull(targetParentDoc.parent_id);
|
||||
let depth = 0;
|
||||
while (cursor && depth < 256) {
|
||||
targetAncestorIds.push(cursor);
|
||||
const parentDoc = await args.client.query(api.documents.getMeta, { id: cursor });
|
||||
if (!parentDoc) break;
|
||||
cursor = trimOrNull(parentDoc.parent_id);
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceDocument: args.sourceDocument,
|
||||
targetParentDocument,
|
||||
targetAncestorIds,
|
||||
};
|
||||
}
|
||||
|
||||
function safeRandomId() {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
@@ -328,28 +375,47 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
|
||||
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
const requestClone = request.clone();
|
||||
let normalizedMove: NormalizedDocumentMovePayload | null = null;
|
||||
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
|
||||
let failureAuthUserId: string | null = null;
|
||||
let failureSourceDocument: MovePreflightDocument | null = null;
|
||||
try {
|
||||
const payload = (await request.json()) as MovePayload;
|
||||
const normalizedMove = normalizeDocumentMovePayload(payload);
|
||||
normalizedMove = normalizeDocumentMovePayload(payload);
|
||||
const documentId = normalizedMove.documentId;
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
failureClient = client;
|
||||
failureAuthUserId = auth.userId;
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
failureSourceDocument = {
|
||||
id: sourceDoc.id,
|
||||
workspaceId: trimOrNull(sourceDoc.workspace_id),
|
||||
parentId: trimOrNull(sourceDoc.parent_id),
|
||||
};
|
||||
|
||||
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||
const movePreflight = await buildMovePreflight({
|
||||
client,
|
||||
sourceDocument: failureSourceDocument,
|
||||
targetParentId: normalizedMove.parentId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: normalizedMove.parentId,
|
||||
sortOrder: normalizedMove.sortOrder,
|
||||
movePreflight,
|
||||
},
|
||||
preflightData: movePreflight,
|
||||
context,
|
||||
target: {
|
||||
pageId: documentId,
|
||||
@@ -373,18 +439,49 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
const fallbackMove = normalizedMove
|
||||
?? normalizeDocumentMovePayload(
|
||||
(await requestClone.json().catch(() => ({}))) as MovePayload,
|
||||
);
|
||||
const documentId = fallbackMove.documentId;
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
let client = failureClient;
|
||||
let authUserId = failureAuthUserId;
|
||||
if (!client || !authUserId) {
|
||||
const authedClient = await getAuthedConvexClient();
|
||||
client = authedClient.client;
|
||||
authUserId = authedClient.auth.userId;
|
||||
}
|
||||
let sourceDocument = failureSourceDocument;
|
||||
if (!sourceDocument) {
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
throw error;
|
||||
}
|
||||
sourceDocument = {
|
||||
id: sourceDoc.id,
|
||||
workspaceId: trimOrNull(sourceDoc.workspace_id),
|
||||
parentId: trimOrNull(sourceDoc.parent_id),
|
||||
};
|
||||
}
|
||||
const context = await buildBridgeContext(request, sourceDocument.workspaceId ?? null, authUserId);
|
||||
const movePreflight = await buildMovePreflight({
|
||||
client,
|
||||
sourceDocument,
|
||||
targetParentId: fallbackMove.parentId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.delete",
|
||||
payload: { documentId },
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: fallbackMove.parentId,
|
||||
sortOrder: fallbackMove.sortOrder,
|
||||
movePreflight,
|
||||
},
|
||||
preflightData: movePreflight,
|
||||
context,
|
||||
target: {
|
||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||
workspaceId: sourceDocument.workspaceId ?? null,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ const mockContext: BridgeContext = {
|
||||
describe("page-write-command-adapter", () => {
|
||||
it("标题命令应走 rust bridge transport", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -94,7 +95,10 @@ describe("page-write-command-adapter", () => {
|
||||
title: "新标题",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await executePageWriteBridgeCommand({
|
||||
context: mockContext,
|
||||
@@ -116,6 +120,25 @@ describe("page-write-command-adapter", () => {
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
commandPayload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
title: "新标题",
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.commandName).toBe("page.head.updateTitle");
|
||||
expect(result.revision).toBeNull();
|
||||
expect(result.conflictDetectionKey).toBeNull();
|
||||
|
||||
@@ -40,6 +40,26 @@ export type PageWriteCommandExecutionResult = {
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
||||
if (!streamDelta) {
|
||||
return commandPayload;
|
||||
}
|
||||
if (isRecord(commandPayload)) {
|
||||
return {
|
||||
...commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||
return {
|
||||
id: payload.documentId,
|
||||
@@ -98,6 +118,33 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUpdatedAt(result: unknown): string | null {
|
||||
const record = isRecord(result) ? result : null;
|
||||
const updatedAt = record?.updated_at;
|
||||
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
|
||||
}
|
||||
|
||||
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
transportResult?: unknown;
|
||||
}) {
|
||||
if (input.envelope.name !== "page.head.updateTitle") {
|
||||
return input.envelope.payload;
|
||||
}
|
||||
|
||||
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
|
||||
const updatedAt = normalizeUpdatedAt(input.transportResult);
|
||||
|
||||
return attachStreamDelta(payload, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
...(updatedAt ? { updated_at: updatedAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
@@ -120,6 +167,10 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
commandPayload: buildPageWriteCommandPayload({
|
||||
envelope: input.envelope,
|
||||
transportResult,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -479,7 +479,10 @@ export async function resolveRustBridgeCommandPlan<TPayload>(input: {
|
||||
const response = await runRustRuntime({
|
||||
kind: "command",
|
||||
context: input.context,
|
||||
command: input.envelope,
|
||||
command: {
|
||||
...input.envelope,
|
||||
preflightData: input.envelope.preflightData ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!("plan" in response) || response.plan.kind !== "command") {
|
||||
|
||||
@@ -44,11 +44,11 @@ describe("tree-command-client", () => {
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/documents/delete",
|
||||
"/api/documents/restore",
|
||||
"/api/documents/purge",
|
||||
"/api/documents/embed",
|
||||
"/api/documents/copy-tree",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/documents/title",
|
||||
"/api/documents/options",
|
||||
]);
|
||||
@@ -70,6 +70,30 @@ describe("tree-command-client", () => {
|
||||
sortOrder: 0,
|
||||
workspaceId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({
|
||||
action: "archive",
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
|
||||
action: "restore",
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[5]?.[1]?.body))).toEqual({
|
||||
action: "purge",
|
||||
documentId: "doc_1",
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[6]?.[1]?.body))).toEqual({
|
||||
action: "embed",
|
||||
sourceId: "doc_1",
|
||||
targetId: "doc_2",
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[7]?.[1]?.body))).toEqual({
|
||||
action: "copy",
|
||||
targetParentId: null,
|
||||
items: [{ documentId: "doc_1", recursive: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("在后端返回错误时抛出统一异常", async () => {
|
||||
|
||||
@@ -96,7 +96,15 @@ type MoveDocumentInput = {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
type TreeCommandAction = "create" | "rename" | "move";
|
||||
type TreeCommandAction =
|
||||
| "create"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "archive"
|
||||
| "restore"
|
||||
| "purge"
|
||||
| "embed"
|
||||
| "copy";
|
||||
|
||||
type DeleteDocumentInput = {
|
||||
documentId: string;
|
||||
@@ -161,12 +169,16 @@ type TreeCommandResponse = {
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} | null;
|
||||
items?: Array<{ oldId: string; newId: string }>;
|
||||
execution?:
|
||||
| ({
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
purged?: boolean;
|
||||
} & Record<string, unknown>)
|
||||
| null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -275,52 +287,92 @@ 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",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "archive",
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"删除失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.archive.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreDocumentCommand(
|
||||
input: RestoreDocumentInput,
|
||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/restore",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "restore",
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"恢复失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.restore.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function purgeDocumentCommand(
|
||||
input: PurgeDocumentInput,
|
||||
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/purge",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "purge",
|
||||
documentId: input.documentId,
|
||||
},
|
||||
"彻底删除失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
purged:
|
||||
typeof response.result?.execution?.purged === "boolean"
|
||||
? response.result.execution.purged
|
||||
: undefined,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.purge.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function embedDocumentCommand(
|
||||
input: EmbedDocumentInput,
|
||||
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/embed",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "embed",
|
||||
sourceId: input.sourceId,
|
||||
targetId: input.targetId,
|
||||
},
|
||||
"嵌入失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.embed.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyTreeCommand(
|
||||
@@ -329,15 +381,21 @@ export async function copyTreeCommand(
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta?: DocumentCommandMeta;
|
||||
}> {
|
||||
return postDocumentCommand<{
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta?: DocumentCommandMeta;
|
||||
}>(
|
||||
"/api/documents/copy-tree",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "copy",
|
||||
targetParentId: input.targetParentId,
|
||||
items: input.items,
|
||||
},
|
||||
"复制页面失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
items: response.result?.items ?? [],
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.copy.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user