feat: 完成 rust cutover phase 8 收口

This commit is contained in:
lix-2026
2026-04-15 20:01:12 +08:00
parent 822c730cd6
commit 98db79b301
104 changed files with 18777 additions and 3025 deletions
@@ -0,0 +1,333 @@
import { randomUUID } from "node:crypto";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
executeRustBridgeQueryTransport,
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
type BlockLike = {
id: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: BlockLike[];
};
function cloneBlock(block: BlockLike): BlockLike {
return {
...block,
props: block.props ? { ...block.props } : undefined,
content: Array.isArray(block.content) ? [...block.content] : block.content,
children: Array.isArray(block.children) ? block.children.map((item) => cloneBlock(item)) : block.children,
};
}
function findBlockInTree(
blocks: BlockLike[],
blockId: string,
): { block: BlockLike; parent: BlockLike | null; index: number } | null {
const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
for (let i = 0; i < current.list.length; i += 1) {
const block = current.list[i]!;
if (block.id === blockId) {
return { block, parent: current.parent, index: i };
}
if (Array.isArray(block.children) && block.children.length > 0) {
stack.push({ list: block.children, parent: block });
}
}
}
return null;
}
function removeBlockSubtree(blocks: BlockLike[], blockId: string) {
const nextTop = blocks.map((item) => cloneBlock(item));
const hit = findBlockInTree(nextTop, blockId);
if (!hit) {
return { removed: null as BlockLike | null, nextBlocks: nextTop };
}
if (hit.parent) {
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
hit.parent.children = nextChildren;
return { removed, nextBlocks: nextTop };
}
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
return { removed, nextBlocks: nextTop };
}
function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unknown) {
const nextTop = blocks.map((item) => cloneBlock(item));
const hit = findBlockInTree(nextTop, blockId);
if (!hit || !nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
return { ok: false, nextBlocks: nextTop };
}
const normalized = cloneBlock({ ...(nextBlock as BlockLike), id: blockId });
if (hit.parent) {
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
nextChildren[hit.index] = normalized;
hit.parent.children = nextChildren;
return { ok: true, nextBlocks: nextTop };
}
nextTop[hit.index] = normalized;
return { ok: true, nextBlocks: nextTop };
}
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
return {
id: randomUUID(),
type: "blockReference",
props: {
sourceDocumentId,
targetBlockId: blockId,
display: "embed",
},
content: [],
children: [],
};
}
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
return await buildDocumentBridgeContext({ request, workspaceId });
}
export async function executeBlockGetBridgeQuery(input: {
request: Request;
sourceDocumentId: string;
blockId: string;
}) {
const context = await buildBridgeContext(input.request, null);
const envelope = buildDocumentQueryEnvelope({
name: "blocks.get",
payload: {
sourceDocumentId: input.sourceDocumentId,
blockId: input.blockId,
},
});
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
const { client } = await getAuthedConvexClient();
const doc = await executeRustBridgeQueryTransport<{ content?: unknown } | null>({
client,
plan,
});
if (!doc) {
throw new Error("页面不存在或无权限");
}
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
const hit = findBlockInTree(blocks, input.blockId);
if (!hit) {
throw new Error("块不存在或无权限");
}
return {
requestId: context.requestId,
traceId: context.traceId,
queryName: envelope.name,
result: { block: hit.block },
};
}
export async function executeBlockPatchBridgeCommand(input: {
request: Request;
sourceDocumentId: string;
workspaceId: string | null;
blockId: string;
nextBlock: unknown;
}) {
const context = await buildBridgeContext(input.request, input.workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "blocks.patch",
payload: {
documentId: input.sourceDocumentId,
workspaceId: input.workspaceId,
blockId: input.blockId,
nextBlock: input.nextBlock,
},
context,
target: {
workspaceId: input.workspaceId,
pageId: input.sourceDocumentId,
blockId: input.blockId,
},
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const { client } = await getAuthedConvexClient();
const doc = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
if (!doc) {
throw new Error("页面不存在或无权限");
}
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
const replaced = replaceBlockInTree(blocks, input.blockId, input.nextBlock);
if (!replaced.ok) {
throw new Error("块不存在或无权限");
}
try {
await executeRustBridgeMutationTransport({
client,
plan,
});
await client.mutation(api.documents.updateContent, {
id: input.sourceDocumentId,
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
});
await recordBridgeCommandArtifacts({ context, envelope });
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
throw error;
}
return {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
result: { ok: true as const },
};
}
export async function executeBlockMoveBridgeCommand(input: {
request: Request;
sourceDocumentId: string;
targetDocumentId: string;
blockId: string;
}) {
const { client } = await getAuthedConvexClient();
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
if (!source) throw new Error("源页面不存在或无权限");
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
if (!target) throw new Error("目标页面不存在或无权限");
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
if (!removedRes.removed) throw new Error("源块不存在或无权限");
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
const context = await buildBridgeContext(input.request, null);
const envelope = buildDocumentCommandEnvelope({
name: "blocks.move",
payload: {
sourceDocumentId: input.sourceDocumentId,
targetDocumentId: input.targetDocumentId,
blockId: input.blockId,
},
context,
target: {
pageId: input.targetDocumentId,
blockId: input.blockId,
},
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
await recordBridgeCommandArtifacts({ context, envelope });
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
throw error;
}
return {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
result: { ok: true as const },
};
}
export async function executeBlockEmbedBridgeCommand(input: {
request: Request;
sourceDocumentId: string;
targetDocumentId: string;
blockId: string;
}) {
const { client } = await getAuthedConvexClient();
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
if (!source) throw new Error("源页面不存在或无权限");
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
if (!target) throw new Error("目标页面不存在或无权限");
const targetMeta = await client.query(api.documents.getMeta, { id: input.targetDocumentId });
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
const hit = findBlockInTree(sourceBlocks, input.blockId);
if (!hit) throw new Error("源块不存在或无权限");
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
const anchorIndex =
typeof anchorId === "string" && anchorId.trim()
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
const nextTargetBlocks = [
...targetBlocks.slice(0, insertIndex),
buildReferenceBlock(input.sourceDocumentId, input.blockId),
...targetBlocks.slice(insertIndex),
];
const context = await buildBridgeContext(input.request, null);
const envelope = buildDocumentCommandEnvelope({
name: "blocks.embed",
payload: {
sourceDocumentId: input.sourceDocumentId,
targetDocumentId: input.targetDocumentId,
blockId: input.blockId,
},
context,
target: {
pageId: input.targetDocumentId,
blockId: input.blockId,
},
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await client.mutation(api.documents.updateContent, {
id: input.targetDocumentId,
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
});
await recordBridgeCommandArtifacts({ context, envelope });
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
throw error;
}
return {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
result: { ok: true as const },
};
}
export function handleBlockBridgeError(error: unknown) {
return documentBridgeErrorResponse(error);
}
+87 -9
View File
@@ -1,8 +1,16 @@
import { api } from "@/lib/convex/api";
import type { BridgeContext, BridgeTarget, CommandEnvelope } from "@/lib/documents/bridge";
import {
DocumentBridgeError,
type BridgeContext,
type BridgeTarget,
type CommandEnvelope,
} from "@/lib/documents/bridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import type { ConvexHttpClient } from "convex/browser";
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
export type BridgeDomainEventStatus = "pending" | "committed" | "rejected" | "failed";
function buildPayloadSummary(commandName: string, context: BridgeContext): string {
return `command=${commandName};request_id=${context.requestId};trace_id=${context.traceId}`;
}
@@ -15,6 +23,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client?: ConvexHttpClient;
status?: BridgeCommandLogStatus;
eventStatus?: BridgeDomainEventStatus;
error?: string | null;
now?: string;
}): Promise<void> {
const workspaceId = normalizeWorkspaceId(input.context, input.envelope.target);
if (!workspaceId) return;
@@ -22,8 +34,15 @@ export async function recordBridgeCommandArtifacts<T>(input: {
const client = input.client ?? (await getAuthedConvexClient()).client;
const commandLogId = `clog_${input.envelope.commandId}`;
const eventId = `evt_${input.envelope.commandId}`;
const now = new Date().toISOString();
const now = input.now ?? new Date().toISOString();
const payload = input.envelope.payload as Record<string, unknown>;
const status = input.status ?? "succeeded";
const eventStatus =
input.eventStatus ??
(status === "pending" ? "pending" : status === "failed" || status === "rolled_back" ? "failed" : "committed");
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
const aggregateId =
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
await client.mutation(api.bridgeLogs.recordCommandLog, {
workspaceId,
@@ -36,16 +55,16 @@ export async function recordBridgeCommandArtifacts<T>(input: {
actorType: input.context.actor.actorType,
sourceChannel: input.context.source.channel,
sourceClient: input.context.source.client,
status: "succeeded",
status,
targetPageId: input.envelope.target?.pageId ?? null,
targetBlockId: input.envelope.target?.blockId ?? null,
payload,
payloadSummary: buildPayloadSummary(input.envelope.name, input.context),
refs: input.envelope.refs,
idempotencyKey: input.envelope.idempotencyKey,
error: null,
error: input.error ?? null,
createdAt: now,
finishedAt: now,
finishedAt: status === "pending" ? null : now,
});
await client.mutation(api.bridgeLogs.recordDomainEvent, {
@@ -56,18 +75,77 @@ export async function recordBridgeCommandArtifacts<T>(input: {
commandId: input.envelope.commandId,
commandLogId,
eventType: `${input.envelope.name}.requested`,
aggregateType: input.envelope.target?.blockId ? "block" : "page",
aggregateId:
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId,
aggregateType,
aggregateId,
eventVersion: 1,
status: "committed",
status: eventStatus,
actorType: input.context.actor.actorType,
payload: {
request_id: input.context.requestId,
trace_id: input.context.traceId,
command_id: input.envelope.commandId,
command_name: input.envelope.name,
idempotency_key: input.envelope.idempotencyKey,
error: input.error ?? null,
},
createdAt: now,
});
}
function normalizeBridgeErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string" && error.trim()) {
return error.trim();
}
return "未知 bridge 错误";
}
function resolveFailureStatuses(error: unknown): {
status: BridgeCommandLogStatus;
eventStatus: BridgeDomainEventStatus;
} {
const details =
error instanceof DocumentBridgeError && error.details && typeof error.details === "object"
? (error.details as Record<string, unknown>)
: null;
const reason = typeof details?.reason === "string" ? details.reason.trim() : "";
if (reason === "rolled_back" || reason === "compensation_applied") {
return {
status: "rolled_back",
eventStatus: "failed",
};
}
if (error instanceof DocumentBridgeError && error.code === "REJECTED") {
return {
status: "failed",
eventStatus: "rejected",
};
}
return {
status: "failed",
eventStatus: "failed",
};
}
export async function recordBridgeCommandFailureArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client?: ConvexHttpClient;
error: unknown;
}): Promise<void> {
const { status, eventStatus } = resolveFailureStatuses(input.error);
try {
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client: input.client,
status,
eventStatus,
error: normalizeBridgeErrorMessage(input.error),
});
} catch (loggingError) {
console.warn("[bridge-log] failure artifacts skipped:", loggingError);
}
}
+241 -17
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("@/lib/auth/authContext", () => ({
@@ -37,6 +37,7 @@ import {
} from "@/lib/documents/bridge";
import { executeMediaAssetWritebackBridgeCommand } from "@/lib/documents/media-asset-command-adapter";
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
import { executePageLifecycleBridgeCommand } from "@/lib/documents/page-command-adapter";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
@@ -46,6 +47,14 @@ vi.mock("@/lib/convex/route", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
resolveRustBridgeQueryPlan: vi.fn(),
executeRustBridgeQueryTransport: vi.fn(),
}));
const mockContext: BridgeContext = {
@@ -70,6 +79,10 @@ const mockContext: BridgeContext = {
dryRun: false,
};
afterEach(() => {
vi.clearAllMocks();
});
describe("documents bridge helpers", () => {
it("assertDocumentId returns trimmed id", () => {
expect(assertDocumentId(" doc_1 ")).toBe("doc_1");
@@ -270,6 +283,45 @@ describe("documents bridge helpers", () => {
});
});
it("buildDocumentBridgeMutationRequest builds page lifecycle runtime request", () => {
const envelope = buildDocumentCommandEnvelope({
name: "documents.create",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
parentId: "parent_1",
title: "无标题",
accessScope: "private",
content: [],
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
});
const request = buildDocumentBridgeMutationRequest({
context: mockContext,
envelope,
mapConvexArgs: (payload) => ({
id: payload.documentId,
workspaceId: payload.workspaceId,
parentId: payload.parentId,
title: payload.title,
accessScope: payload.accessScope,
content: payload.content,
}),
});
expect(request.functionName).toBe("documents:createWithParentReference");
expect(request.args).toEqual({
id: "doc_1",
workspaceId: "ws_1",
parentId: "parent_1",
title: "无标题",
accessScope: "private",
content: [],
});
});
it("buildDocumentBridgeContextWithActor keeps explicit actor and source", () => {
const request = new Request("http://127.0.0.1:3001/api/onlyoffice/callback", {
headers: {
@@ -308,14 +360,34 @@ describe("documents bridge helpers", () => {
});
it("executeMetadataBridgeCommand routes title update through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.title.update",
commandId: "cmd_title_1",
functionName: "documents:updateTitle",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
title: "新标题",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
const result = await executeMetadataBridgeCommand({
context: mockContext,
@@ -331,10 +403,21 @@ describe("documents bridge helpers", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
id: "doc_1",
title: "新标题",
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.title.update",
}),
});
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
client: expect.any(Object),
plan: expect.objectContaining({
functionName: "documents:updateTitle",
argsJson: {
id: "doc_1",
title: "新标题",
},
}),
});
expect(result.commandName).toBe("documents.title.update");
});
@@ -390,16 +473,41 @@ describe("documents bridge helpers", () => {
});
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.save",
commandId: "cmd_save_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 7,
conflict_detection_key: "conflict_1",
});
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
const payload = buildDocumentSavePayload({
@@ -422,12 +530,23 @@ describe("documents bridge helpers", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.save",
}),
});
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
client: expect.any(Object),
plan: expect.objectContaining({
functionName: "documents:updateContent",
argsJson: {
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
},
}),
});
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
previousBridgeArtifactCalls + 1,
@@ -446,14 +565,38 @@ describe("documents bridge helpers", () => {
});
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
const mutation = vi.fn().mockRejectedValue(new Error("正文内容已变更,请刷新后重试"));
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.save",
commandId: "cmd_save_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockRejectedValue(
new Error("正文内容已变更,请刷新后重试"),
);
const payload = buildDocumentSavePayload({
documentId: "doc_1",
@@ -480,6 +623,87 @@ describe("documents bridge helpers", () => {
});
});
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.create",
commandId: "cmd_create_1",
functionName: "documents:createWithParentReference",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
workspaceId: "ws_1",
parentId: "parent_1",
title: "无标题",
accessScope: "private",
content: [],
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
id: "doc_1",
title: "无标题",
});
const result = await executePageLifecycleBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.create",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
parentId: "parent_1",
title: "无标题",
accessScope: "private",
content: [],
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.create",
}),
});
expect(executeRustBridgeMutationTransport).toHaveBeenCalledWith({
client: expect.any(Object),
plan: expect.objectContaining({
functionName: "documents:createWithParentReference",
}),
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.create",
}),
});
expect(result.result).toEqual({
id: "doc_1",
title: "无标题",
});
});
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
@@ -96,7 +96,36 @@ export type DocumentBridgeMutationRequest<
args: TArgs;
};
export type DocumentBridgeQueryRequest<
TArgs extends Record<string, unknown> = Record<string, unknown>,
> = {
functionName: string;
deploymentId: string | null;
projectId: string | null;
workspaceId: string | null;
requestId: string;
traceId: string;
actorId: string;
payloadJson: string;
args: TArgs;
};
const DOCUMENT_BRIDGE_QUERY_FUNCTIONS = {
"documents.content.get": "documents:getContent",
"documents.meta.get": "documents:getMeta",
"blocks.get": "documents:getContent",
} as const satisfies Record<string, string>;
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.create": "documents:createWithParentReference",
"documents.move": "documents:move",
"documents.delete": "documents:softDelete",
"documents.restore": "documents:restore",
"documents.duplicate": "documents:duplicateWithMindmaps",
"documents.copy_tree": "documents:copyTree",
"blocks.patch": "documents:updateContent",
"blocks.move": "documents:updateContent",
"blocks.embed": "documents:updateContent",
"documents.title.update": "documents:updateTitle",
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
@@ -303,6 +332,28 @@ function buildDocumentCommandPayloadJson(input: {
});
}
function buildDocumentQueryPayloadJson(input: {
context: BridgeContext;
queryName: string;
workspaceId: string | null;
}): string {
return JSON.stringify({
kind: "query",
name: input.queryName,
request_id: input.context.requestId,
trace_id: input.context.traceId,
deployment_id: input.context.deploymentId,
project_id: input.context.projectId,
workspace_id: input.workspaceId,
tenant_id: input.context.tenantId,
actor_id: input.context.actor.actorId,
source: {
channel: input.context.source.channel,
client: input.context.source.client,
},
});
}
export function buildDocumentBridgeMutationRequest<
TPayload,
TArgs extends Record<string, unknown>,
@@ -333,6 +384,47 @@ export function buildDocumentBridgeMutationRequest<
};
}
function getDocumentBridgeQueryFunctionName(queryName: string): string {
const functionName =
DOCUMENT_BRIDGE_QUERY_FUNCTIONS[
queryName as keyof typeof DOCUMENT_BRIDGE_QUERY_FUNCTIONS
];
if (!functionName) {
throw new DocumentBridgeError(
`未注册文档 bridge query: ${queryName}`,
500,
"TRANSPORT_ERROR",
);
}
return functionName;
}
export function buildDocumentBridgeQueryRequest<
TPayload,
TArgs extends Record<string, unknown>,
>(input: {
context: BridgeContext;
envelope: QueryEnvelope<TPayload>;
mapConvexArgs: (payload: TPayload) => TArgs;
}): DocumentBridgeQueryRequest<TArgs> {
const workspaceId = input.context.workspaceId ?? null;
return {
functionName: getDocumentBridgeQueryFunctionName(input.envelope.name),
deploymentId: input.context.deploymentId,
projectId: input.context.projectId,
workspaceId,
requestId: input.context.requestId,
traceId: input.context.traceId,
actorId: input.context.actor.actorId,
payloadJson: buildDocumentQueryPayloadJson({
context: input.context,
queryName: input.envelope.name,
workspaceId,
}),
args: input.mapConvexArgs(input.envelope.payload),
};
}
export async function executeDocumentBridgeMutationRequest<
TArgs extends Record<string, unknown>,
TResult,
@@ -1,4 +1,5 @@
import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api";
import {
buildDocumentBridgeMutationRequest,
@@ -6,7 +7,10 @@ import {
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
export type MediaAssetReplaceStoragePayload = {
assetId: string;
@@ -34,15 +38,25 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
mapConvexArgs: (payload) => ({
userId: payload.userId,
id: payload.assetId,
storageId: payload.storageId as any,
storageId: payload.storageId as Id<"_storage">,
}),
});
await executeDocumentBridgeMutationRequest({
client: input.client,
mutation: api.mediaAssets.replaceStorageFromUpload,
request: mutationRequest,
});
try {
await executeDocumentBridgeMutationRequest({
client: input.client,
mutation: api.mediaAssets.replaceStorageFromUpload,
request: mutationRequest,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client: input.client,
context: input.context,
envelope: input.envelope,
error,
});
throw error;
}
try {
await recordBridgeCommandArtifacts({
@@ -6,7 +6,14 @@ import {
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
export type DocumentTitleUpdatePayload = {
@@ -106,19 +113,40 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<MetadataCommandExecutionResult> {
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
const { client } = await getAuthedConvexClient();
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs,
});
try {
if (input.envelope.name === "documents.title.update") {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
await executeRustBridgeMutationTransport({
client,
plan,
});
} else {
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs,
});
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation,
request: mutationRequest,
});
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation,
request: mutationRequest,
});
}
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client,
error,
});
throw error;
}
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
@@ -0,0 +1,534 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import type { ConvexHttpClient } from "convex/browser";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
assertDocumentId,
buildDocumentBridgeContext,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
type BridgeContext,
type CommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
import type { Json } from "@/types/supabase";
export type DocumentCreatePayload = {
documentId: string;
workspaceId: string;
parentId: string | null;
title: string;
accessScope: "private" | "shared" | "public";
content: unknown;
};
export type DocumentMovePayload = {
documentId: string;
workspaceId: string | null;
parentId: string | null;
sortOrder: number;
};
export type DocumentDeletePayload = {
documentId: string;
workspaceId: string | null;
};
export type DocumentRestorePayload = {
documentId: string;
workspaceId: string | null;
};
export type DocumentDuplicatePayload = {
sourceDocumentId: string;
newDocumentId: string;
workspaceId: string | null;
title: string | null;
};
export type DocumentCopyTreePayload = {
workspaceId: string | null;
targetParentId: string | null;
items: Array<{
documentId: string;
recursive: boolean;
}>;
};
export type PageCommandExecutionResult<TResult> = {
requestId: string;
traceId: string;
commandId: string;
commandName: string;
result: TResult;
};
function trimOrNull(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeTitle(title: string | null | undefined): string {
const safe = typeof title === "string" ? title.trim() : "";
return safe.length > 0 ? safe : "无标题";
}
function safeRandomId(): string {
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : randomUUID();
}
function normalizeWorkspaceId(value: string | null | undefined): string | null {
return trimOrNull(value);
}
async function withAuthedClient() {
const { auth, client } = await getAuthedConvexClient();
return { auth, client };
}
async function buildRuntimeContext(request: Request, workspaceId: string | null) {
return buildDocumentBridgeContext({
request,
workspaceId,
});
}
async function recordLifecycleArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client: ConvexHttpClient;
}) {
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client: input.client,
});
}
async function recordLifecycleFailureArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client: ConvexHttpClient;
error: unknown;
}) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client: input.client,
error: input.error,
});
}
export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
client?: ConvexHttpClient;
}): Promise<PageCommandExecutionResult<TResult>> {
const client = input.client ?? (await getAuthedConvexClient()).client;
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const result = await executeRustBridgeMutationTransport<TResult>({
client,
plan,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
client,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
result,
};
}
export async function executeDocumentCreateChildBridgeCommand(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as {
parentId?: string | null;
title?: string;
blocks?: unknown;
};
if (typeof payload.parentId === "undefined") {
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
}
const { auth, client } = await withAuthedClient();
const parentId = trimOrNull(payload.parentId);
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: safeRandomId(),
});
let workspaceId: string | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = normalizeWorkspaceId(parentDoc.workspace_id);
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
workspaceId = normalizeWorkspaceId(workspaceBootstrap.activeWorkspaceId);
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const resolvedTitle = normalizeTitle(payload.title);
const contentPayload = Array.isArray(payload.blocks) ? payload.blocks : [];
const pageId = safeRandomId();
const context = await buildRuntimeContext(request, workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.createChild",
payload: {
documentId: pageId,
workspaceId,
parentId,
title: resolvedTitle,
accessScope,
content: contentPayload,
},
context,
target: {
workspaceId,
pageId,
},
});
let created;
try {
created = await client.mutation(api.documents.create, {
id: pageId,
workspaceId,
parentId,
title: resolvedTitle,
accessScope,
content: contentPayload,
});
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
return NextResponse.json({
pageId: created.id,
title: created.title ?? resolvedTitle,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
export async function executeDocumentEmbedBridgeCommand(request: Request): Promise<NextResponse> {
try {
const { sourceId, targetId } = (await request.json()) as {
sourceId?: string;
targetId?: string;
};
const normalizedSourceId = assertDocumentId(sourceId);
const normalizedTargetId = assertDocumentId(targetId);
const { client } = await withAuthedClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedSourceId });
if (!sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
}
const targetContent = await client.query(api.documents.getContent, { id: normalizedTargetId });
if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id);
const anchorIndex =
anchorId
? currentBlocks.findIndex(
(block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId,
)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks.slice(0, insertIndex),
{
id: safeRandomId(),
type: "pageReference",
props: {
pageId: normalizedSourceId,
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
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 envelope = buildDocumentCommandEnvelope({
name: "documents.embed",
payload: {
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
},
context,
target: {
workspaceId,
pageId: normalizedTargetId,
},
});
try {
await client.mutation(api.documents.updateContent, {
id: normalizedTargetId,
content: payload,
});
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
return NextResponse.json({
ok: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
export async function executeDocumentTemplateBridgeCommand(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as {
documentId?: string;
isTemplate?: boolean;
};
const normalizedDocumentId = assertDocumentId(payload.documentId);
if (typeof payload.isTemplate !== "boolean") {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { client } = await withAuthedClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
}
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id);
const context = await buildRuntimeContext(request, workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.template",
payload: {
documentId: normalizedDocumentId,
isTemplate: payload.isTemplate,
},
context,
target: {
workspaceId,
pageId: normalizedDocumentId,
},
});
try {
await client.mutation(api.documents.setTemplate, {
id: normalizedDocumentId,
isTemplate: payload.isTemplate,
});
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
return NextResponse.json({
ok: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
export async function executeDocumentEmptyTrashBridgeCommand(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as {
workspaceId?: string;
};
const workspaceId = normalizeWorkspaceId(payload.workspaceId);
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { client } = await withAuthedClient();
const context = await buildRuntimeContext(request, workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.emptyTrashByWorkspace",
payload: {
workspaceId,
},
context,
target: {
workspaceId,
},
});
try {
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
return NextResponse.json({
success: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
export async function executeDocumentPurgeBridgeCommand(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as {
documentId?: string;
};
const normalizedDocumentId = assertDocumentId(payload.documentId);
const { client } = await withAuthedClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: normalizedDocumentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
}
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id);
const context = await buildRuntimeContext(request, workspaceId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.purge",
payload: {
documentId: normalizedDocumentId,
},
context,
target: {
workspaceId,
pageId: normalizedDocumentId,
},
});
try {
await client.mutation(api.documents.purge, { id: normalizedDocumentId });
await recordLifecycleArtifacts({
context,
envelope,
client,
});
} catch (error) {
await recordLifecycleFailureArtifacts({
context,
envelope,
client,
error,
});
throw error;
}
return NextResponse.json({
success: true,
meta: {
requestId: context.requestId,
traceId: context.traceId,
commandId: envelope.commandId,
commandName: envelope.name,
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
@@ -0,0 +1,654 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContextWithActor,
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
type BridgeContext,
type CommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
type CreatePayload = {
parentId?: string | null;
};
type MovePayload = {
documentId?: string | null;
parentId?: string | null;
position?: number | null;
};
type DeletePayload = {
documentId?: string | null;
};
type RestorePayload = {
documentId?: string | null;
};
type DuplicatePayload = {
documentId?: string | null;
};
type CopyTreeItem = {
documentId: string;
recursive: boolean;
};
type CopyTreePayload = {
items?: CopyTreeItem[] | null;
targetParentId?: string | null;
};
const documentsBaseDir = getDocumentsBaseDir();
function trimOrNull(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeTitle(title: string | null): string {
const safe = title?.trim();
return safe && safe.length > 0 ? safe : "无标题";
}
function safeRandomId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: randomUUID();
}
async function ensureDocumentScaffold(id: string, title: string | null) {
const folder = path.join(documentsBaseDir, id);
const indexFile = path.join(folder, "index.md");
await fs.mkdir(folder, { recursive: true });
try {
await fs.access(indexFile);
} catch {
const safeTitle = normalizeTitle(title);
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
}
}
async function copyMindmapIfExists(sourceId: string, targetId: string) {
try {
const srcDir = path.join(documentsBaseDir, sourceId);
const destDir = path.join(documentsBaseDir, targetId);
const entries = await fs.readdir(srcDir);
const mindmapFiles = entries.filter((name) => name === "mindmap.json" || /^mindmap-.+\.json$/i.test(name));
if (mindmapFiles.length === 0) return;
await fs.mkdir(destDir, { recursive: true });
await Promise.all(
mindmapFiles.map(async (name) => {
try {
const buf = await fs.readFile(path.join(srcDir, name));
await fs.writeFile(path.join(destDir, name), buf);
} catch {
// ignore
}
}),
);
} catch {
// 如果源文件不存在就跳过
}
}
async function buildBridgeContext(request: Request, workspaceId: string | null, authUserId: string): Promise<BridgeContext> {
const sessionId = trimOrNull(request.headers.get("x-session-id")) ?? trimOrNull(request.headers.get("x-mnote-session-id"));
return buildDocumentBridgeContextWithActor({
request,
workspaceId,
actor: {
actorType: "user",
actorId: authUserId,
sessionId,
},
});
}
async function resolveCommandPlan<TPayload>(input: {
request: Request;
workspaceId: string | null;
authUserId: string;
envelope: CommandEnvelope<TPayload>;
}) {
const context = await buildBridgeContext(input.request, input.workspaceId, input.authUserId);
const plan = await resolveRustBridgeCommandPlan({
context,
envelope: input.envelope,
});
return { context, plan };
}
async function handleLifecycleError(error: unknown) {
return documentBridgeErrorResponse(error);
}
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as CreatePayload;
const { auth, client } = await getAuthedConvexClient();
const parentId = trimOrNull(payload.parentId);
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: safeRandomId(),
});
let workspaceId: string | null = null;
let accessScope: "private" | "shared" | "public" = "private";
if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
}
workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else {
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const documentId = safeRandomId();
const context = await buildBridgeContext(request, workspaceId, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.create",
payload: {
documentId,
workspaceId,
parentId,
title: "无标题",
accessScope,
content: [],
},
context,
target: {
workspaceId,
pageId: documentId,
},
});
const { context, plan } = await resolveCommandPlan({
request,
workspaceId,
authUserId: auth.userId,
envelope,
});
const created = await executeRustBridgeMutationTransport<{
id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean;
created_at: string;
updated_at: string;
workspace_id: string;
access_scope: "private" | "shared" | "public";
is_template: boolean;
}>({
client,
plan,
});
if (created?.id) {
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
}
await recordBridgeCommandArtifacts({
context,
envelope,
client,
});
return NextResponse.json(created);
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as MovePayload;
const documentId = trimOrNull(payload.documentId);
if (documentId) {
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId,
parentId: trimOrNull(payload.parentId),
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
},
context,
target: {
workspaceId: sourceDoc?.workspace_id ?? null,
pageId: documentId,
},
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
}
} catch {
// 忽略日志写失败,保留原始错误返回
}
return handleLifecycleError(error);
}
}
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as MovePayload;
const documentId = trimOrNull(payload.documentId);
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.move",
payload: {
documentId,
parentId: trimOrNull(payload.parentId),
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
},
context,
target: {
pageId: documentId,
},
});
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId: null,
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
context: runtimeContext,
envelope,
client,
});
return NextResponse.json({ ok: true });
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
const documentId = trimOrNull(payload.documentId);
if (documentId) {
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.delete",
payload: { documentId },
context,
target: {
workspaceId: sourceDoc?.workspace_id ?? null,
pageId: documentId,
},
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
}
} catch {
// 忽略日志写失败,保留原始错误返回
}
return handleLifecycleError(error);
}
}
export async function handleDocumentDeleteRequest(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as DeletePayload;
const documentId = trimOrNull(payload.documentId);
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.delete",
payload: { documentId },
context,
target: {
pageId: documentId,
},
});
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId: null,
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
context: runtimeContext,
envelope,
client,
});
return NextResponse.json({ success: true });
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as RestorePayload;
const documentId = trimOrNull(payload.documentId);
if (documentId) {
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.restore",
payload: { documentId },
context,
target: {
workspaceId: sourceDoc?.workspace_id ?? null,
pageId: documentId,
},
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
}
} catch {
// 忽略日志写失败,保留原始错误返回
}
return handleLifecycleError(error);
}
}
export async function handleDocumentRestoreRequest(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as RestorePayload;
const documentId = trimOrNull(payload.documentId);
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.restore",
payload: { documentId },
context,
target: {
pageId: documentId,
},
});
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId: null,
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
context: runtimeContext,
envelope,
client,
});
return NextResponse.json({ success: true });
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
const documentId = trimOrNull(payload.documentId);
if (documentId) {
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
const workspaceId = sourceDoc?.workspace_id ?? null;
const context = await buildBridgeContext(request, workspaceId, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.duplicate",
payload: {
sourceDocumentId: documentId,
newDocumentId: "duplicate_failed",
title: sourceDoc?.title ?? null,
},
context,
target: {
workspaceId,
},
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
}
} catch {
// 忽略日志写失败,保留原始错误返回
}
return handleLifecycleError(error);
}
}
export async function handleDocumentDuplicateRequest(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as DuplicatePayload;
const documentId = trimOrNull(payload.documentId);
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
}
const workspaceId = sourceDoc.workspace_id ?? null;
const context = await buildBridgeContext(request, workspaceId, auth.userId);
const fallbackTitle = normalizeTitle(sourceDoc.title);
const duplicatedTitle = `${fallbackTitle} 副本`;
const newId = safeRandomId();
const envelope = buildDocumentCommandEnvelope({
name: "documents.duplicate",
payload: {
sourceDocumentId: documentId,
newDocumentId: newId,
title: duplicatedTitle,
},
context,
target: {
workspaceId,
pageId: newId,
},
});
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId,
authUserId: auth.userId,
envelope,
});
const duplicated = await executeRustBridgeMutationTransport<{
id: string;
title: string | null;
parent_id: string | null;
sort_order: number | null;
workspace_id: string;
access_scope: "private" | "shared" | "public";
created_at: string;
updated_at: string;
}>({
client,
plan,
});
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await copyMindmapIfExists(documentId, duplicated.id);
await recordBridgeCommandArtifacts({
context: runtimeContext,
envelope,
client,
});
return NextResponse.json({
id: duplicated.id,
title: duplicated.title ?? duplicatedTitle,
parent_id: duplicated.parent_id ?? null,
sort_order: duplicated.sort_order ?? null,
});
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
const { auth, client } = await getAuthedConvexClient();
const context = await buildBridgeContext(request, null, auth.userId);
const envelope = buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
items: (payload.items ?? []).map((item) => ({
documentId: trimOrNull(item.documentId) ?? "unknown",
recursive: Boolean(item.recursive),
})),
targetParentId: trimOrNull(payload.targetParentId),
},
context,
});
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
error,
});
} catch {
// 忽略日志写失败,保留原始错误返回
}
return handleLifecycleError(error);
}
}
export async function handleDocumentCopyTreeRequest(request: Request): Promise<NextResponse> {
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) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = trimOrNull(payload.targetParentId);
let workspaceId: string | null = null;
if (targetParentId) {
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => trimOrNull(it.documentId) as string)));
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
if (!firstMeta) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
if (!workspaceId) {
workspaceId = firstMeta.workspace_id;
}
if (!workspaceId) {
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
}
const context = await buildBridgeContext(request, workspaceId, auth.userId);
const outerEnvelope = buildDocumentCommandEnvelope({
name: "documents.copy_tree",
payload: {
items: normalizedItems.map((item) => ({
documentId: trimOrNull(item.documentId) as string,
recursive: Boolean(item.recursive),
})),
targetParentId,
},
context,
target: {
workspaceId,
pageId: targetParentId ?? undefined,
},
});
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId,
authUserId: auth.userId,
envelope: outerEnvelope,
});
const result = await executeRustBridgeMutationTransport<{
items: Array<{ oldId: string; newId: string }>;
}>({
client,
plan,
});
const insertedDocs = Array.isArray(result?.items) ? result.items : [];
for (const item of insertedDocs) {
const nextDoc = await client.query(api.documents.getMeta, { id: item.newId });
await ensureDocumentScaffold(item.newId, nextDoc?.title ?? null);
await copyMindmapIfExists(item.oldId, item.newId);
}
await recordBridgeCommandArtifacts({
context: runtimeContext,
envelope: outerEnvelope,
client,
});
return NextResponse.json({
items: insertedDocs,
});
} catch (error) {
return handleLifecycleError(error);
}
}
@@ -0,0 +1,49 @@
import "server-only";
import path from "node:path";
import { promises as fs } from "node:fs";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
const documentsBaseDir = getDocumentsBaseDir();
export async function ensureDocumentScaffold(id: string, title: string | null) {
const folder = path.join(documentsBaseDir, id);
const indexFile = path.join(folder, "index.md");
await fs.mkdir(folder, { recursive: true });
try {
await fs.access(indexFile);
} catch {
const safeTitle = title && title.trim() ? title.trim() : "无标题";
await fs.writeFile(indexFile, `# ${safeTitle}\n`, "utf8");
}
}
export async function copyMindmapFilesIfExists(sourceId: string, targetId: string) {
const srcDir = path.join(documentsBaseDir, sourceId);
const destDir = path.join(documentsBaseDir, targetId);
try {
const entries = await fs.readdir(srcDir);
const mindmapFiles = entries.filter(
(name) => name === "mindmap.json" || /^mindmap-.+\.json$/i.test(name),
);
if (mindmapFiles.length === 0) return;
await fs.mkdir(destDir, { recursive: true });
await Promise.all(
mindmapFiles.map(async (name) => {
try {
const sourceFile = path.join(srcDir, name);
const targetFile = path.join(destDir, name);
const buffer = await fs.readFile(sourceFile);
await fs.writeFile(targetFile, buffer);
} catch {
// 说明:本地思维导图副作用失败不应反向打断主页面操作。
}
}),
);
} catch {
// 说明:源页面不存在本地思维导图文件时直接忽略。
}
}
@@ -0,0 +1,638 @@
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import { api } from "@/lib/convex/api";
import {
DocumentBridgeError,
type BridgeTarget,
type BridgeContext,
type CommandEnvelope,
type QueryEnvelope,
} from "@/lib/documents/bridge";
export type RustRuntimeExecutedQuery<TResult = unknown> = {
ok: true;
result: TResult;
};
type RustRuntimeErrorKind =
| "validation"
| "unauthorized"
| "conflict"
| "not_found"
| "transport"
| "rejected";
type RustRuntimeErrorPayload = {
kind: RustRuntimeErrorKind | string;
message: string;
};
type RustRuntimeResponse =
| {
ok: true;
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
}
| RustRuntimeExecutedQuery
| {
ok: false;
error: RustRuntimeErrorPayload;
};
export type RustBridgeQueryPlan = {
kind: "query";
queryName: string;
functionName: string;
workspaceId: string | null;
requestId: string;
traceId: string;
actorId: string;
payloadJson: string;
argsJson: Record<string, unknown>;
};
export type RustBridgeCommandPlan = {
kind: "command";
commandName: string;
commandId: string;
functionName: string;
workspaceId: string | null;
requestId: string;
traceId: string;
actorId: string;
idempotencyKey: string | null;
payloadJson: string;
argsJson: Record<string, unknown>;
};
export type RustBridgeToolPlanStep = {
kind: string;
name: string;
functionName: string | null;
description: string;
argsJson: Record<string, unknown>;
};
export type RustBridgeToolPlan = {
kind: "tool";
toolName: string;
invocationKind: string;
executionMode: string;
effect: string;
toolsetId: string;
requiresConfirmation: boolean;
requestId: string;
traceId: string;
actorId: string;
validateOnly: boolean;
dryRun: boolean;
payloadJson: string;
argsJson: Record<string, unknown>;
target: Record<string, unknown> | null;
steps: RustBridgeToolPlanStep[];
};
export type RustBridgeBuiltinToolPlan = {
kind: "builtin_tool";
toolName: string;
toolsetId: string;
status: "rust" | "mixed" | "ts" | "transport";
note: string;
};
export type RustBridgeToolResult<TResult = unknown> = {
plan: RustBridgeToolPlan;
result: TResult;
};
type RuntimeProcessResult = {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
};
type RuntimeInvocation = {
command: string;
args: string[];
};
const RUST_RUNTIME_TIMEOUT_MS = 30_000;
async function pathExists(targetPath: string) {
try {
await access(targetPath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
async function resolveRepoRoot() {
const candidates = [process.cwd(), path.resolve(process.cwd(), "..")];
for (const candidate of candidates) {
if (await pathExists(path.join(candidate, "rust", "Cargo.toml"))) {
return candidate;
}
}
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
}
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
if (explicitBin) {
return {
command: explicitBin,
args: [],
};
}
const repoRoot = await resolveRepoRoot();
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
if (await pathExists(builtBinary)) {
return {
command: builtBinary,
args: [],
};
}
return {
command: "cargo",
args: [
"run",
"--quiet",
"--manifest-path",
path.join(repoRoot, "rust", "Cargo.toml"),
"-p",
"bridge-runtime",
"--",
],
};
}
async function runRustRuntime(input: Record<string, unknown>): Promise<RustRuntimeResponse> {
const invocation = await resolveRuntimeInvocation();
const result = await new Promise<RuntimeProcessResult>((resolve, reject) => {
const child = spawn(invocation.command, invocation.args, {
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
CARGO_TERM_COLOR: "never",
},
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, RUST_RUNTIME_TIMEOUT_MS);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
if (timedOut) {
reject(new DocumentBridgeError("Rust runtime 执行超时", 504, "TRANSPORT_ERROR"));
return;
}
resolve({
code,
signal,
stdout,
stderr,
});
});
child.stdin.end(JSON.stringify(input));
}).catch((error: unknown) => {
if (error instanceof DocumentBridgeError) {
throw error;
}
throw new DocumentBridgeError(
error instanceof Error ? `Rust runtime 启动失败: ${error.message}` : "Rust runtime 启动失败",
500,
"TRANSPORT_ERROR",
);
});
const raw = result.stdout.trim();
if (!raw) {
throw new DocumentBridgeError(
result.stderr.trim() || "Rust runtime 未返回任何结果",
500,
"TRANSPORT_ERROR",
);
}
let parsed: RustRuntimeResponse;
try {
parsed = JSON.parse(raw) as RustRuntimeResponse;
} catch (error) {
throw new DocumentBridgeError(
`Rust runtime 返回了非法 JSON: ${error instanceof Error ? error.message : "unknown"}`,
500,
"TRANSPORT_ERROR",
{
stdout: raw,
stderr: result.stderr.trim() || null,
},
);
}
if (result.code !== 0 || !parsed.ok) {
if (!parsed.ok) {
throw toDocumentBridgeError("error" in parsed ? parsed.error : { kind: "transport", message: "Rust runtime 执行失败" });
}
throw new DocumentBridgeError(
result.stderr.trim() ||
`Rust runtime 执行失败(code=${String(result.code)}, signal=${String(result.signal)})`,
500,
"TRANSPORT_ERROR",
);
}
return parsed;
}
function toDocumentBridgeError(error: RustRuntimeErrorPayload) {
switch (error.kind) {
case "validation":
return new DocumentBridgeError(error.message, 400, "VALIDATION_ERROR");
case "unauthorized":
return new DocumentBridgeError(error.message, 401, "UNAUTHORIZED");
case "not_found":
return new DocumentBridgeError(error.message, 404, "NOT_FOUND");
case "conflict":
return new DocumentBridgeError(error.message, 409, "REJECTED");
case "rejected":
return new DocumentBridgeError(error.message, 409, "REJECTED");
case "transport":
default:
return new DocumentBridgeError(error.message, 500, "TRANSPORT_ERROR");
}
}
function assertObjectArgs(argsJson: Record<string, unknown>) {
if (!argsJson || typeof argsJson !== "object" || Array.isArray(argsJson)) {
throw new DocumentBridgeError("Rust runtime 返回了非法 transport args", 500, "TRANSPORT_ERROR");
}
return argsJson;
}
function assertToolPlan(plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan): RustBridgeToolPlan {
if (plan.kind !== "tool") {
throw new DocumentBridgeError("Rust runtime 返回了错误的 tool plan", 500, "TRANSPORT_ERROR");
}
return {
...plan,
argsJson: assertObjectArgs(plan.argsJson),
target:
plan.target && typeof plan.target === "object" && !Array.isArray(plan.target)
? (plan.target as Record<string, unknown>)
: null,
steps: Array.isArray(plan.steps)
? plan.steps.map((step) => ({
kind: String(step.kind ?? ""),
name: String(step.name ?? ""),
functionName: typeof step.functionName === "string" ? step.functionName : null,
description: String(step.description ?? ""),
argsJson:
step.argsJson && typeof step.argsJson === "object" && !Array.isArray(step.argsJson)
? (step.argsJson as Record<string, unknown>)
: {},
}))
: [],
};
}
function assertStringArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
if (typeof value !== "string" || !value.trim()) {
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
return value;
}
function readOptionalIntegerArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
if (value === null || typeof value === "undefined") {
return null;
}
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
throw new DocumentBridgeError(`Rust runtime 的 ${field} 非法`, 500, "TRANSPORT_ERROR");
}
function readOptionalStringArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
if (value === null || typeof value === "undefined") {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || null;
}
throw new DocumentBridgeError(`Rust runtime 的 ${field} 非法`, 500, "TRANSPORT_ERROR");
}
function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
context: BridgeContext;
envelope: QueryEnvelope<TPayload>;
}): Promise<RustBridgeQueryPlan> {
const response = await runRustRuntime({
kind: "query",
context: input.context,
query: input.envelope,
});
if (!("plan" in response) || response.plan.kind !== "query") {
throw new DocumentBridgeError("Rust runtime 返回了错误的 query plan", 500, "TRANSPORT_ERROR");
}
return {
...response.plan,
argsJson: assertObjectArgs(response.plan.argsJson),
};
}
export async function executeRustBridgeQuery<TResult>(input: {
context: BridgeContext;
envelope: QueryEnvelope<unknown>;
data?: Record<string, unknown>;
}): Promise<TResult> {
const response = await runRustRuntime({
kind: "query",
context: input.context,
query: input.envelope,
data: input.data ?? {},
});
if (!("result" in response)) {
throw new DocumentBridgeError("Rust runtime 未返回 query result", 500, "TRANSPORT_ERROR");
}
return response.result as TResult;
}
export async function resolveRustBridgeCommandPlan<TPayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<RustBridgeCommandPlan> {
const response = await runRustRuntime({
kind: "command",
context: input.context,
command: input.envelope,
});
if (!("plan" in response) || response.plan.kind !== "command") {
throw new DocumentBridgeError("Rust runtime 返回了错误的 command plan", 500, "TRANSPORT_ERROR");
}
return {
...response.plan,
argsJson: assertObjectArgs(response.plan.argsJson),
};
}
export async function resolveRustBridgeToolPlan(input: {
context: BridgeContext;
toolName: string;
invocationKind: "command" | "query" | "job";
args: Record<string, unknown>;
target?: BridgeTarget | null;
reason?: string | null;
refs?: string[];
mode?: "plan" | "result" | "explain-plan";
}): Promise<RustBridgeToolPlan> {
const response = await runRustRuntime({
kind: "tool",
context: input.context,
tool: {
tool: input.toolName,
kind: input.invocationKind,
mode: input.mode ?? "plan",
argsJson: input.args,
target: input.target ?? null,
reason: input.reason ?? null,
refs: input.refs ?? [],
},
});
if (!("plan" in response)) {
throw new DocumentBridgeError("Rust runtime 未返回 tool plan", 500, "TRANSPORT_ERROR");
}
if (response.plan.kind !== "tool") {
throw new DocumentBridgeError("Rust runtime 返回了错误的 tool plan", 500, "TRANSPORT_ERROR");
}
return assertToolPlan(response.plan);
}
export async function executeRustBridgeTool<TResult>(input: {
context: BridgeContext;
toolName: string;
invocationKind: "command" | "query" | "job";
args: Record<string, unknown>;
data: Record<string, unknown>;
target?: BridgeTarget | null;
reason?: string | null;
refs?: string[];
mode?: "result" | "explain-plan";
}): Promise<RustBridgeToolResult<TResult>> {
const response = await runRustRuntime({
kind: "tool",
context: input.context,
tool: {
tool: input.toolName,
kind: input.invocationKind,
mode: input.mode ?? "result",
argsJson: input.args,
target: input.target ?? null,
reason: input.reason ?? null,
refs: input.refs ?? [],
},
data: input.data,
});
if (!("result" in response)) {
throw new DocumentBridgeError("Rust runtime 未返回 tool result", 500, "TRANSPORT_ERROR");
}
const plan = await resolveRustBridgeToolPlan({
context: input.context,
toolName: input.toolName,
invocationKind: input.invocationKind,
args: input.args,
target: input.target,
reason: input.reason,
refs: input.refs,
mode: input.mode === "explain-plan" ? "explain-plan" : "plan",
});
return {
plan,
result: response.result as TResult,
};
}
export async function executeRustBridgeQueryTransport<TResult>(input: {
client: ConvexHttpClient;
plan: RustBridgeQueryPlan;
}): Promise<TResult> {
const bridgeLogsApi = api as any;
const query = input.client.query.bind(input.client) as (
queryReference: unknown,
args: Record<string, unknown>,
) => Promise<TResult>;
switch (input.plan.functionName) {
case "documents:getContent":
return query(api.documents.getContent, {
id: assertStringArg(input.plan.argsJson, "id"),
});
case "mindmaps:get":
return query(api.mindmaps.get, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
});
case "sidebar:datasetList":
return query(api.sidebar.datasetList, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
});
case "blocks:getById":
return query(api.blocks.getById, {
userId: assertStringArg(input.plan.argsJson, "userId"),
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
blockId: assertStringArg(input.plan.argsJson, "blockId"),
});
case "bridgeLogs:listByRequest":
return query(bridgeLogsApi.bridgeLogs.listByRequest, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
requestId: assertStringArg(input.plan.argsJson, "requestId"),
});
case "bridgeLogs:listByTrace":
return query(bridgeLogsApi.bridgeLogs.listByTrace, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
traceId: assertStringArg(input.plan.argsJson, "traceId"),
});
case "bridgeLogs:listByCommand":
return query(bridgeLogsApi.bridgeLogs.listByCommand, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
commandId: assertStringArg(input.plan.argsJson, "commandId"),
});
case "bridgeLogs:listWorkspaceOverview":
return query(bridgeLogsApi.bridgeLogs.listWorkspaceOverview, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
limit:
typeof input.plan.argsJson.limit === "number" && Number.isFinite(input.plan.argsJson.limit)
? input.plan.argsJson.limit
: undefined,
cursor: readOptionalStringArg(input.plan.argsJson, "cursor"),
commandStatus: readOptionalStringArg(input.plan.argsJson, "commandStatus"),
eventStatus: readOptionalStringArg(input.plan.argsJson, "eventStatus"),
targetPageId: readOptionalStringArg(input.plan.argsJson, "targetPageId"),
targetBlockId: readOptionalStringArg(input.plan.argsJson, "targetBlockId"),
aggregateType: readOptionalStringArg(input.plan.argsJson, "aggregateType"),
aggregateId: readOptionalStringArg(input.plan.argsJson, "aggregateId"),
});
default:
throw new DocumentBridgeError(
`未注册的 Rust query transport: ${input.plan.functionName}`,
500,
"TRANSPORT_ERROR",
);
}
}
export async function executeRustBridgeMutationTransport<TResult>(input: {
client: ConvexHttpClient;
plan: RustBridgeCommandPlan;
}): Promise<TResult> {
const mutation = input.client.mutation.bind(input.client) as (
mutationReference: unknown,
args: Record<string, unknown>,
) => Promise<TResult>;
switch (input.plan.functionName) {
case "documents:createWithParentReference":
return mutation(api.documents.create, {
id: assertStringArg(input.plan.argsJson, "id"),
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
title: assertStringArg(input.plan.argsJson, "title"),
accessScope: assertStringArg(input.plan.argsJson, "accessScope"),
content: input.plan.argsJson.content,
});
case "documents:move":
return mutation(api.documents.move, {
id: assertStringArg(input.plan.argsJson, "id"),
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
});
case "documents:softDelete":
return mutation(api.documents.softDelete, {
id: assertStringArg(input.plan.argsJson, "id"),
});
case "documents:restore":
return mutation(api.documents.restore, {
id: assertStringArg(input.plan.argsJson, "id"),
});
case "documents:duplicateWithMindmaps":
return mutation(api.documents.duplicate, {
sourceId: assertStringArg(input.plan.argsJson, "sourceId"),
newId: assertStringArg(input.plan.argsJson, "newId"),
title: readOptionalStringArg(input.plan.argsJson, "title"),
});
case "documents:updateTitle":
return mutation(api.documents.updateTitle, {
id: assertStringArg(input.plan.argsJson, "id"),
title: assertStringArg(input.plan.argsJson, "title"),
});
case "documents:updateContent":
return mutation(api.documents.updateContent, {
id: assertStringArg(input.plan.argsJson, "id"),
content: input.plan.argsJson.content,
expectedRevision: readOptionalIntegerArg(input.plan.argsJson, "expectedRevision"),
conflictDetectionKey: readOptionalStringArg(input.plan.argsJson, "conflictDetectionKey"),
});
case "mindmaps:put":
return mutation(api.mindmaps.put, {
docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
data: input.plan.argsJson.data,
createOnly:
typeof input.plan.argsJson.createOnly === "boolean"
? input.plan.argsJson.createOnly
: undefined,
});
default:
throw new DocumentBridgeError(
`未注册的 Rust mutation transport: ${input.plan.functionName}`,
500,
"TRANSPORT_ERROR",
);
}
}
@@ -1,14 +1,18 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import { DocumentBridgeError } from "@/lib/documents/bridge";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export type DocumentSaveExecutionResult = {
requestId: string;
@@ -24,25 +28,27 @@ export async function executeSaveBridgeCommand(input: {
envelope: CommandEnvelope<DocumentSavePayload>;
}): Promise<DocumentSaveExecutionResult> {
const { client } = await getAuthedConvexClient();
const mutationRequest = buildDocumentBridgeMutationRequest({
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
mapConvexArgs: (payload) => ({
id: payload.documentId,
content: payload.content,
expectedRevision: payload.revision,
conflictDetectionKey: payload.conflictDetectionKey,
}),
});
let mutationResult;
try {
mutationResult = await executeDocumentBridgeMutationRequest({
mutationResult = await executeRustBridgeMutationTransport<{
revision?: number | null;
conflict_detection_key?: string | null;
}>({
client,
mutation: api.documents.updateContent,
request: mutationRequest,
plan,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
context: input.context,
envelope: input.envelope,
error,
});
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
reason: "content_conflict",