feat: continue tree rust family cutover

- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker
- route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans
- preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/convex/api", () => ({
api: {
documents: {
getContent: "documents:getContent",
},
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
resolveRustBridgeQueryPlan: vi.fn(),
executeRustBridgeQueryTransport: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
}));
vi.mock("@/lib/document-content", () => ({
extractBlocksFromContent: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
});
describe("documents/block-command-adapter", () => {
it("executeBlockPatchBridgeCommand 应改走 Rust artifact writer", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
recordBridgeCommandArtifacts,
recordRustBridgeCommandArtifacts,
} = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
const { extractBlocksFromContent } = await import("@/lib/document-content");
const { executeBlockPatchBridgeCommand } = await import("./block-command-adapter");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
query: vi.fn().mockResolvedValue({
content: { type: "doc" },
}),
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(extractBlocksFromContent).mockReturnValue([
{
id: "blk_1",
type: "paragraph",
},
]);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "blocks.patch",
commandId: "cmd_block_patch_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",
blockId: "blk_1",
nextBlock: {
id: "blk_1",
type: "heading",
},
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 8,
conflict_detection_key: "doc_1:8",
});
const result = await executeBlockPatchBridgeCommand({
request: new Request("http://127.0.0.1:3000/api/documents/blocks/patch", {
method: "POST",
}),
sourceDocumentId: "doc_1",
workspaceId: "ws_1",
blockId: "blk_1",
nextBlock: {
id: "blk_1",
type: "heading",
},
});
expect(result.commandName).toBe("blocks.patch");
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: expect.objectContaining({
workspaceId: "ws_1",
}),
envelope: expect.objectContaining({
name: "blocks.patch",
}),
plan: expect.objectContaining({
commandName: "blocks.patch",
}),
result: {
revision: 8,
conflict_detection_key: "doc_1:8",
},
});
expect(recordBridgeCommandArtifacts).not.toHaveBeenCalled();
});
});
@@ -8,8 +8,8 @@ import {
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
@@ -164,11 +164,17 @@ export async function executeBlockPatchBridgeCommand(input: {
throw new Error("块不存在或无权限");
}
try {
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({ context, envelope });
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
@@ -217,8 +223,14 @@ export async function executeBlockMoveBridgeCommand(input: {
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await recordBridgeCommandArtifacts({ context, envelope });
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
@@ -276,8 +288,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
try {
await executeRustBridgeMutationTransport({ client, plan });
await recordBridgeCommandArtifacts({ context, envelope });
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
await recordRustBridgeCommandArtifacts({
client,
context,
envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
client,
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
import type { BridgeContext, CommandEnvelope } from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
vi.mock("@/lib/convex/api", () => ({
api: {
bridgeLogs: {
recordCommandLog: "bridgeLogs.recordCommandLog",
recordDomainEvent: "bridgeLogs.recordDomainEvent",
},
},
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge", () => ({
DocumentBridgeError: class DocumentBridgeError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string,
public readonly details?: unknown,
) {
super(message);
this.name = "DocumentBridgeError";
}
},
}));
const context: BridgeContext = {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
};
const envelope: CommandEnvelope<{ documentId: string }> = {
name: "tree.node.archive",
commandId: "cmd_1",
idempotencyKey: "idem_1",
actor: context.actor,
source: context.source,
target: {
workspaceId: "ws_1",
pageId: "page_1",
},
payload: {
documentId: "page_1",
},
reason: null,
refs: ["test"],
dryRun: false,
validateOnly: false,
};
describe("bridge-log", () => {
it("记录成功 artifact 时应把 streamDelta 同步写入 domain event payload", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
await recordBridgeCommandArtifacts({
context,
envelope,
client: { mutation } as unknown as ConvexHttpClient,
now: "2026-04-26T00:00:00.000Z",
domainEventType: "tree.node.archived",
commandPayload: {
documentId: "page_1",
streamDelta: {
op: "remove_document",
documentId: "page_1",
},
},
});
expect(mutation).toHaveBeenNthCalledWith(
2,
"bridgeLogs.recordDomainEvent",
expect.objectContaining({
eventType: "tree.node.archived",
payload: expect.objectContaining({
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.node.archived",
aggregate: {
type: "page",
id: "page_1",
},
command_id: "cmd_1",
command_name: "tree.node.archive",
command: {
id: "cmd_1",
name: "tree.node.archive",
idempotencyKey: "idem_1",
},
trace: {
requestId: "req_1",
traceId: "trace_1",
},
error: null,
streamDelta: {
op: "remove_document",
documentId: "page_1",
},
}),
}),
);
});
it("记录 artifact 时应优先消费 Rust domainEventPlan", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
await recordBridgeCommandArtifacts({
context,
envelope,
client: { mutation } as unknown as ConvexHttpClient,
now: "2026-04-26T00:00:00.000Z",
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDelta: {
op: "move_document",
documentId: "page_1",
parentId: "parent_1",
sortOrder: 0,
},
},
domainEventType: "legacy.should_not_win",
commandPayload: {
documentId: "page_1",
},
});
expect(mutation).toHaveBeenNthCalledWith(
2,
"bridgeLogs.recordDomainEvent",
expect.objectContaining({
eventType: "tree.subtree.moved",
payload: expect.objectContaining({
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDelta: {
op: "move_document",
documentId: "page_1",
parentId: "parent_1",
sortOrder: 0,
},
}),
}),
);
});
});
+79 -8
View File
@@ -6,6 +6,7 @@ import {
type CommandEnvelope,
} from "@/lib/documents/bridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
import type { ConvexHttpClient } from "convex/browser";
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
@@ -19,11 +20,74 @@ function normalizeWorkspaceId(context: BridgeContext, target?: BridgeTarget | nu
return target?.workspaceId?.trim() || context.workspaceId?.trim() || null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readStreamDelta(payload: unknown): unknown {
if (!isRecord(payload)) {
return null;
}
return payload.streamDelta ?? payload.stream_delta ?? null;
}
function normalizeDomainEventType(raw: unknown): string | null {
if (typeof raw !== "string") {
return null;
}
const trimmed = raw.trim();
return trimmed || null;
}
function buildTreeDomainEventPayload(input: {
context: BridgeContext;
commandName: string;
commandId: string;
idempotencyKey?: string | null;
eventType: string;
aggregateType: string;
aggregateId: string;
streamDelta: unknown;
error?: string | null;
}) {
const payload: Record<string, unknown> = {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: input.eventType,
aggregate: {
type: input.aggregateType,
id: input.aggregateId,
},
trace: {
requestId: input.context.requestId,
traceId: input.context.traceId,
},
command: {
id: input.commandId,
name: input.commandName,
idempotencyKey: input.idempotencyKey ?? null,
},
error: input.error ?? null,
// 兼容既有观测与 SSE 解析字段,正式消费者应优先使用上面的结构化字段。
request_id: input.context.requestId,
trace_id: input.context.traceId,
command_id: input.commandId,
command_name: input.commandName,
idempotency_key: input.idempotencyKey ?? null,
};
if (input.streamDelta) {
payload.streamDelta = input.streamDelta;
}
return payload;
}
export async function recordBridgeCommandArtifacts<T>(input: {
context: BridgeContext;
envelope: CommandEnvelope<T>;
client?: ConvexHttpClient;
commandPayload?: unknown;
domainEventPlan?: RustTreeDomainEventPlan | null;
domainEventType?: string | null;
status?: BridgeCommandLogStatus;
eventStatus?: BridgeDomainEventStatus;
error?: string | null;
@@ -44,6 +108,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
const aggregateId =
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
const streamDelta = input.domainEventPlan?.streamDelta ?? readStreamDelta(payload);
const eventType =
normalizeDomainEventType(input.domainEventPlan?.eventType) ??
normalizeDomainEventType(input.domainEventType) ?? `${input.envelope.name}.requested`;
await client.mutation(api.bridgeLogs.recordCommandLog, {
workspaceId,
@@ -75,20 +143,23 @@ export async function recordBridgeCommandArtifacts<T>(input: {
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandLogId,
eventType: `${input.envelope.name}.requested`,
eventType,
aggregateType,
aggregateId,
eventVersion: 1,
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,
payload: buildTreeDomainEventPayload({
context: input.context,
commandName: input.envelope.name,
commandId: input.envelope.commandId,
idempotencyKey: input.envelope.idempotencyKey,
eventType,
aggregateType,
aggregateId,
streamDelta,
error: input.error ?? null,
},
}),
createdAt: now,
});
}
+208 -39
View File
@@ -48,6 +48,7 @@ vi.mock("@/lib/convex/route", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
@@ -478,14 +479,43 @@ describe("documents bridge helpers", () => {
});
it("executeMetadataBridgeCommand routes options update through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = 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(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.options.update",
commandId: "cmd_options_1",
functionName: "documents:updateOptions",
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",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const result = await executeMetadataBridgeCommand({
context: mockContext,
@@ -505,31 +535,119 @@ describe("documents bridge helpers", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
id: "doc_1",
options: {
wideLayout: undefined,
smallText: undefined,
showHeadingNumbers: undefined,
showToc: true,
showStructure: undefined,
protectEditing: undefined,
showWordCount: undefined,
collapseBacklinks: undefined,
pageFont: undefined,
layoutDensity: "compact",
hideChildPages: undefined,
showBlockRefCount: undefined,
embedDefaultBlockId: null,
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.options.update",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.options.update",
}),
plan: expect.objectContaining({
commandName: "documents.options.update",
functionName: "documents:updateOptions",
}),
result: {
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
},
});
expect(result.commandName).toBe("documents.options.update");
});
it("executeMetadataBridgeCommand routes stats update through rust runtime", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = 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().mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
}),
} as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.stats.update",
commandId: "cmd_stats_1",
functionName: "documents:updateStats",
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",
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const result = await executeMetadataBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.stats.update",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
stats: {
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
},
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.stats.update",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.stats.update",
}),
plan: expect.objectContaining({
commandName: "documents.stats.update",
functionName: "documents:updateStats",
}),
result: {
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
},
});
expect(result.commandName).toBe("documents.stats.update");
});
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -540,7 +658,7 @@ describe("documents bridge helpers", () => {
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.save",
@@ -563,7 +681,7 @@ describe("documents bridge helpers", () => {
revision: 7,
conflict_detection_key: "conflict_1",
});
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
const previousBridgeArtifactCalls = vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length;
const payload = buildDocumentSavePayload({
documentId: "doc_1",
@@ -603,16 +721,25 @@ describe("documents bridge helpers", () => {
},
}),
});
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
expect(vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length).toBe(
previousBridgeArtifactCalls + 1,
);
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.save",
target: { workspaceId: "ws_1", pageId: "doc_1" },
payload,
}),
plan: expect.objectContaining({
commandName: "documents.save",
functionName: "documents:updateContent",
}),
result: {
revision: 7,
conflict_detection_key: "conflict_1",
},
});
expect(result.requestId).toBe("req_1");
expect(result.traceId).toBe("trace_1");
@@ -680,7 +807,7 @@ 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 { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -691,7 +818,7 @@ describe("documents bridge helpers", () => {
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "documents.create",
@@ -746,12 +873,20 @@ describe("documents bridge helpers", () => {
functionName: "documents:createWithParentReference",
}),
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "documents.create",
}),
plan: expect.objectContaining({
commandName: "documents.create",
functionName: "documents:createWithParentReference",
}),
result: {
id: "doc_1",
title: "无标题",
},
});
expect(result.result).toEqual({
id: "doc_1",
@@ -760,13 +895,39 @@ describe("documents bridge helpers", () => {
});
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");
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "media.assets.replace_storage",
commandId: "cmd_asset_1",
functionName: "mediaAssets:replaceStorageFromUpload",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
userId: "user_1",
id: "asset_1",
documentId: "doc_1",
workspaceId: "ws_1",
storageId: "storage_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
fileUrl: "https://example.com/file.docx",
});
await executeMediaAssetWritebackBridgeCommand({
client: {
mutation,
mutation: vi.fn(),
} as unknown as ConvexHttpClient,
context: mockContext,
envelope: buildDocumentCommandEnvelope({
@@ -783,18 +944,26 @@ describe("documents bridge helpers", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
userId: "user_1",
id: "asset_1",
storageId: "storage_1",
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "media.assets.replace_storage",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "media.assets.replace_storage",
}),
plan: expect.objectContaining({
commandName: "media.assets.replace_storage",
functionName: "mediaAssets:replaceStorageFromUpload",
}),
result: {
ok: true,
fileUrl: "https://example.com/file.docx",
},
});
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
assertDocumentMoveOrderPlanMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "../../../convex/_utils/documentMoveOrder";
describe("documentMoveOrder", () => {
it("按 Rust canonical move order plan 计算跨父移动和越界 clamp", () => {
const plan = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "target",
parent_id: null,
sort_order: 0,
created_at: "2026-04-25T00:00:00Z",
},
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
{
id: "doc_c",
parent_id: "target",
sort_order: 0,
created_at: "2026-04-25T00:00:03Z",
},
],
documentId: "doc_b",
parentId: "target",
sortOrder: -2,
});
expect(plan).toEqual({
documentId: "doc_b",
fromParentId: "source",
toParentId: "target",
requestedSortOrder: -2,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: "target",
sortOrder: 0,
moved: true,
},
{
documentId: "doc_c",
parentId: "target",
sortOrder: 1,
moved: false,
},
],
});
});
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => {
const actual = buildDocumentMoveOrderPlanFromDocuments({
documents: [
{
id: "doc_a",
parent_id: "source",
sort_order: 0,
created_at: "2026-04-25T00:00:01Z",
},
{
id: "doc_b",
parent_id: "source",
sort_order: 1,
created_at: "2026-04-25T00:00:02Z",
},
{
id: "doc_c",
parent_id: "target",
sort_order: 0,
created_at: "2026-04-25T00:00:03Z",
},
],
documentId: "doc_b",
parentId: "target",
sortOrder: 0,
});
expect(() =>
assertDocumentMoveOrderPlanMatches(
{
...actual,
patches: actual.patches.map((patch) =>
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
),
},
actual,
),
).toThrow("Rust move plan 与 Convex 当前排序状态不一致");
});
});
@@ -1,16 +1,16 @@
import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
export type MediaAssetReplaceStoragePayload = {
assetId: string;
@@ -32,21 +32,21 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
context: BridgeContext;
envelope: CommandEnvelope<MediaAssetReplaceStoragePayload>;
}): Promise<MediaAssetWritebackExecutionResult> {
const mutationRequest = buildDocumentBridgeMutationRequest({
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
mapConvexArgs: (payload) => ({
userId: payload.userId,
id: payload.assetId,
storageId: payload.storageId as Id<"_storage">,
}),
});
try {
await executeDocumentBridgeMutationRequest({
const transportResult = await executeRustBridgeMutationTransport({
client: input.client,
mutation: api.mediaAssets.replaceStorageFromUpload,
request: mutationRequest,
plan,
});
await recordRustBridgeCommandArtifacts({
client: input.client,
context: input.context,
envelope: input.envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -58,17 +58,6 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
throw error;
}
try {
await recordBridgeCommandArtifacts({
client: input.client,
context: input.context,
envelope: input.envelope,
});
} catch (error) {
// 说明:OnlyOffice callback 写回的主事实是附件存储替换;日志落账失败不应反向导致保存失败。
console.warn("[onlyoffice/callback] bridge log write skipped:", error);
}
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
@@ -1,14 +1,11 @@
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,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
executeRustBridgeMutationTransport,
@@ -47,111 +44,27 @@ export type MetadataCommandExecutionResult = {
commandName: string;
};
type MetadataMutationArgs = Record<string, unknown>;
type MetadataWriteAdapter<TPayload> = {
convexMutation: unknown;
mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
};
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
"documents.title.update": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"page.head.updateTitle": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"documents.stats.update": {
convexMutation: api.documents.updateStats,
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
id: payload.documentId,
wordCount: payload.stats.wordCount,
characterCount: payload.stats.characterCount,
blockCount: payload.stats.blockCount,
todoTotal: payload.stats.todoTotal,
todoDone: payload.stats.todoDone,
}),
},
"documents.options.update": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.layout.updateOptions": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
};
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
const adapter = metadataWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面元信息命令适配器: ${commandName}`);
}
return adapter as MetadataWriteAdapter<TPayload>;
}
export async function executeMetadataBridgeCommand<TPayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<MetadataCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
try {
if (
input.envelope.name === "documents.title.update" ||
input.envelope.name === "page.head.updateTitle"
) {
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,
});
}
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
plan,
result: transportResult,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
@@ -161,10 +74,6 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
});
throw error;
}
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
@@ -12,8 +12,8 @@ import {
type CommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import {
@@ -147,10 +147,12 @@ export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
client,
plan,
result,
});
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -43,6 +43,7 @@ vi.mock("@/lib/documents/bridge", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
@@ -12,8 +12,8 @@ import {
type CommandEnvelope,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
@@ -257,9 +257,20 @@ async function handleLifecycleError(error: unknown) {
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
assertServerEnvironment();
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
let failureContext: BridgeContext | null = null;
let failureEnvelope: CommandEnvelope<{
documentId: string;
workspaceId: string;
parentId: string | null;
title: string;
accessScope: "private" | "shared" | "public";
content: unknown[];
}> | null = null;
try {
const payload = (await request.json()) as CreatePayload;
const { auth, client } = await getAuthedConvexClient();
failureClient = client;
const parentId = trimOrNull(payload.parentId);
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
@@ -304,6 +315,8 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
pageId: documentId,
},
});
failureContext = context;
failureEnvelope = envelope;
const { context: runtimeContext, plan } = await resolveCommandPlan({
request,
workspaceId,
@@ -331,38 +344,22 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
}
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: created,
});
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,
},
});
if (failureClient && failureContext && failureEnvelope) {
await recordBridgeCommandFailureArtifacts({
client,
context,
envelope,
client: failureClient,
context: failureContext,
envelope: failureEnvelope,
error,
});
}
@@ -427,14 +424,16 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: transportResult,
});
return NextResponse.json({ ok: true });
} catch (error) {
@@ -529,14 +528,16 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: transportResult,
});
return NextResponse.json({ success: true });
} catch (error) {
@@ -548,7 +549,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
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",
name: "documents.delete",
payload: { documentId },
context,
target: {
@@ -600,14 +601,16 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
authUserId: auth.userId,
envelope,
});
await executeRustBridgeMutationTransport({
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: transportResult,
});
return NextResponse.json({ success: true });
} catch (error) {
@@ -620,12 +623,8 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
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,
},
name: "documents.restore",
payload: { documentId },
context,
target: {
workspaceId,
@@ -689,8 +688,10 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
title: string | null;
parent_id: string | null;
sort_order: number | null;
is_starred: boolean;
workspace_id: string;
access_scope: "private" | "shared" | "public";
is_template: boolean;
created_at: string;
updated_at: string;
}>({
@@ -701,10 +702,12 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await copyMindmapIfExists(documentId, duplicated.id);
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope,
client,
plan,
result: duplicated,
});
return NextResponse.json({
@@ -715,26 +718,32 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
});
} catch (error) {
try {
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
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,
});
const documentId = trimOrNull(payload.documentId);
if (documentId) {
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 {
// 忽略日志写失败,保留原始错误返回
}
@@ -809,10 +818,12 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
await copyMindmapIfExists(item.oldId, item.newId);
}
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: runtimeContext,
envelope: outerEnvelope,
client,
plan,
result,
});
return NextResponse.json({
@@ -37,6 +37,7 @@ vi.mock("@/lib/convex/route", () => ({
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
recordRustBridgeCommandArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
@@ -69,7 +70,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 { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
@@ -120,23 +121,18 @@ describe("page-write-command-adapter", () => {
name: "page.head.updateTitle",
}),
});
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
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",
},
},
plan: expect.objectContaining({
commandName: "page.head.updateTitle",
}),
result: {
ok: true,
updated_at: "2026-04-24T00:00:00.000Z",
},
});
expect(result.commandName).toBe("page.head.updateTitle");
@@ -144,12 +140,41 @@ describe("page-write-command-adapter", () => {
expect(result.conflictDetectionKey).toBeNull();
});
it("页面设置命令应走 bridge mutation request", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
it("页面设置命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const { recordRustBridgeCommandArtifacts } = 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 } as unknown as ConvexHttpClient,
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.layout.updateOptions",
commandId: "cmd_options_1",
functionName: "documents:updateOptions",
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",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const result = await executePageWriteBridgeCommand({
@@ -170,7 +195,27 @@ describe("page-write-command-adapter", () => {
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "page.layout.updateOptions",
}),
});
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
client: expect.any(Object),
context: mockContext,
envelope: expect.objectContaining({
name: "page.layout.updateOptions",
}),
plan: expect.objectContaining({
commandName: "page.layout.updateOptions",
functionName: "documents:updateOptions",
}),
result: {
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
},
});
expect(result.commandName).toBe("page.layout.updateOptions");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
@@ -1,15 +1,12 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type BridgeContext,
type CommandEnvelope,
DocumentBridgeError,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
@@ -23,14 +20,6 @@ type PageWritePayload =
| DocumentOptionsUpdatePayload
| DocumentSavePayload;
type MetadataMutationArgs = Record<string, unknown>;
type PageWriteAdapter<TPayload> = {
kind: "rust_transport" | "convex_mutation";
convexMutation?: unknown;
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
};
export type PageWriteCommandExecutionResult = {
requestId: string;
traceId: string;
@@ -40,70 +29,6 @@ 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,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
"page.head.updateTitle": {
kind: "rust_transport",
},
"page.layout.updateOptions": {
kind: "convex_mutation",
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.body.save": {
kind: "rust_transport",
},
};
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
const adapter = pageWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面写命令适配器: ${commandName}`);
}
return adapter as PageWriteAdapter<TPayload>;
}
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
return {
@@ -118,86 +43,29 @@ 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>;
}): Promise<PageWriteCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
try {
if (adapter.kind === "rust_transport") {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
commandPayload: buildPageWriteCommandPayload({
envelope: input.envelope,
transportResult,
}),
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
}
const mutationRequest = buildDocumentBridgeMutationRequest({
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs!,
});
await executeDocumentBridgeMutationRequest({
const transportResult = await executeRustBridgeMutationTransport({
client,
mutation: adapter.convexMutation!,
request: mutationRequest,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
plan,
result: transportResult,
});
return {
@@ -205,8 +73,8 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: null,
conflictDetectionKey: null,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
@@ -1,4 +1,43 @@
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
import {
buildRustBridgeCommandArtifactPlan,
executeRustBridgeMutationTransport,
materializeRustTreeStreamDelta,
readRustTreeDomainEventPlan,
readRustTreeDomainEventType,
type RustBridgeCommandPlan,
} from "./rust-runtime";
vi.mock("@/lib/convex/api", () => ({
api: {
documents: {
move: "documents.move",
copyTree: "documents.copyTree",
updateStats: "documents.updateStats",
},
mediaAssets: {
batchCopy: "mediaAssets.batchCopy",
batchMove: "mediaAssets.batchMove",
},
},
}));
vi.mock("@/lib/documents/bridge", () => ({
DocumentBridgeError: class DocumentBridgeError extends Error {
status: number;
code: string;
details?: unknown;
constructor(message: string, status: number, code: string, details?: unknown) {
super(message);
this.name = "DocumentBridgeError";
this.status = status;
this.code = code;
this.details = details;
}
},
}));
let runtimeSelection: Record<string, unknown> = {};
@@ -41,3 +80,609 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
).toBe(true);
});
});
describe("executeRustBridgeMutationTransport", () => {
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
const normalizedMove = {
documentId: "doc_b",
fromParentId: "source",
toParentId: "target",
requestedSortOrder: 0,
normalizedSortOrder: 0,
patches: [
{
documentId: "doc_b",
parentId: "target",
sortOrder: 0,
moved: true,
},
],
};
const mutation = vi.fn().mockResolvedValue({ ok: true });
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_move",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
id: "doc_b",
parentId: "target",
sortOrder: 0,
normalizedMove,
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toMatchObject({
id: "doc_b",
parentId: "target",
sortOrder: 0,
normalizedMove,
});
});
it("documents.copyTree 应注册为 Rust tree.subtree.copy 的 transport", async () => {
const mutation = vi.fn().mockResolvedValue({ items: [] });
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.copy",
commandId: "cmd_copy",
functionName: "documents:copyTree",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
targetParentId: "parent_1",
items: [
{
documentId: "doc_1",
recursive: true,
},
],
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).toHaveBeenCalledWith("documents.copyTree", {
targetParentId: "parent_1",
items: [
{
documentId: "doc_1",
recursive: true,
},
],
});
});
it("documents.updateStats 应注册为 Rust metadata transport", async () => {
const mutation = vi.fn().mockResolvedValue({
ok: true,
updated_at: "2026-04-26T00:00:00.000Z",
});
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "documents.stats.update",
commandId: "cmd_stats",
functionName: "documents:updateStats",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
id: "doc_1",
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).toHaveBeenCalledWith("documents.updateStats", {
id: "doc_1",
wordCount: 12,
characterCount: 34,
blockCount: 5,
todoTotal: 6,
todoDone: 2,
});
});
it("tree.resource.copy/move 应把 Rust resourceTransferPlan 透传给媒体 transport", async () => {
const mutation = vi.fn().mockResolvedValue({ items: [] });
const basePlan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.resource.copy",
commandId: "cmd_asset_copy",
functionName: "mediaAssets:batchCopy",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: basePlan,
});
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan: {
...basePlan,
commandName: "tree.resource.move",
commandId: "cmd_asset_move",
functionName: "mediaAssets:batchMove",
argsJson: {
...basePlan.argsJson,
resourceTransferPlan: {
action: "move",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
},
},
});
expect(mutation).toHaveBeenNthCalledWith(1, "mediaAssets.batchCopy", {
userId: "user_1",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
resourceTransferPlan: {
action: "copy",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
});
expect(mutation).toHaveBeenNthCalledWith(2, "mediaAssets.batchMove", {
userId: "user_1",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
resourceTransferPlan: {
action: "move",
assetIds: ["asset_1", "asset_2"],
targetDocumentId: "doc_target",
targetSubPath: "mindmaps/mind_1",
},
});
});
});
describe("materializeRustTreeStreamDelta", () => {
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_move",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_requested",
sortOrder: 9,
},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: {
parent_id: "parent_actual",
sort_order: 2,
updated_at: "2026-04-26T00:00:00Z",
},
}),
).toEqual({
op: "move_document",
documentId: "doc_1",
parentId: "parent_actual",
sortOrder: 2,
updatedAt: "2026-04-26T00:00:00Z",
});
});
it("应按 Rust copy_result hint 从结果 items 中生成 upsert_documents delta", () => {
const document = {
id: "copy_1",
workspace_id: "ws_1",
title: "复制页面",
parent_id: "parent_1",
sort_order: 0,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.copy",
commandId: "cmd_copy",
functionName: "documents:copyTree",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "copy_result",
args: {
itemsField: "items",
documentField: "document",
},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: {
items: [
{
oldId: "doc_1",
newId: "copy_1",
document,
},
],
},
}),
).toEqual({
op: "upsert_documents",
upsertDocuments: [document],
});
});
it("应按 Rust result_document hint 从根结果生成 upsert_document delta", () => {
const document = {
id: "copy_2",
workspace_id: "ws_1",
title: "复制页面 2",
parent_id: "parent_1",
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "documents.duplicate",
commandId: "cmd_duplicate",
functionName: "documents:duplicateWithMindmaps",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "result_document",
args: {},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: document,
}),
).toEqual({
op: "upsert_document",
document,
});
});
it("应按 Rust asset_result hint 从结果 items 中生成 upsert_assets delta", () => {
const asset = {
id: "asset_1",
workspace_id: "ws_1",
document_id: "doc_target",
asset_type: "file",
file_url: "/file.pdf",
thumbnail_url: "/file.pdf",
file_name: "file.pdf",
file_size: 1024,
mime_type: "application/pdf",
ocr_text: null,
ocr_status: null,
created_at: "2026-04-26T00:00:00Z",
updated_at: "2026-04-26T00:00:00Z",
};
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.resource.move",
commandId: "cmd_asset_move",
functionName: "mediaAssets:batchMove",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
streamDeltaHint: {
family: "tree",
kind: "asset_result",
args: {
itemsField: "items",
},
},
},
};
expect(
materializeRustTreeStreamDelta({
plan,
result: {
items: [asset],
},
}),
).toEqual({
op: "upsert_assets",
upsertAssets: [asset],
});
});
});
describe("readRustTreeDomainEventType", () => {
it("应从 Rust domainEventHint 读取正式树域 event type", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.node.archive",
commandId: "cmd_archive",
functionName: "documents:softDelete",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
domainEventHint: {
family: "tree",
eventType: "tree.node.archived",
},
},
};
expect(readRustTreeDomainEventType(plan)).toBe("tree.node.archived");
});
it("应优先读取 Rust domainEventPlan 作为正式树域事件计划", () => {
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_move",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: null,
payloadJson: "{}",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
},
},
domainEventHint: {
family: "tree",
eventType: "legacy.should_not_win",
},
},
};
expect(readRustTreeDomainEventType(plan)).toBe("tree.subtree.moved");
expect(readRustTreeDomainEventPlan(plan)).toEqual({
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
},
});
});
});
describe("buildRustBridgeCommandArtifactPlan", () => {
it("应通过 Rust runtime commandArtifact 输入生成 artifact plan", async () => {
const artifactPlan = await buildRustBridgeCommandArtifactPlan({
context: {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_artifact_1",
traceId: "trace_artifact_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
},
envelope: {
name: "tree.subtree.move",
commandId: "cmd_artifact_1",
idempotencyKey: "idem_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: null,
},
source: {
channel: "next-route",
client: "vitest",
},
target: {
workspaceId: "ws_1",
pageId: "doc_1",
},
payload: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
preflightData: null,
reason: "test",
refs: ["test"],
dryRun: false,
validateOnly: false,
},
plan: {
kind: "command",
commandName: "tree.subtree.move",
commandId: "cmd_artifact_1",
functionName: "documents:move",
workspaceId: "ws_1",
requestId: "req_artifact_1",
traceId: "trace_artifact_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
domainEventPlan: {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType: "tree.subtree.moved",
streamDeltaHint: {
family: "tree",
kind: "move_document",
args: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
},
},
},
},
},
result: {
parent_id: "parent_1",
sort_order: 2,
updated_at: "2026-04-26T10:00:00Z",
},
now: "2026-04-26T10:00:01Z",
});
expect(artifactPlan?.commandLog).toMatchObject({
id: "clog_cmd_artifact_1",
workspaceId: "ws_1",
commandName: "tree.subtree.move",
payload: {
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 0,
streamDelta: {
op: "move_document",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-26T10:00:00Z",
},
},
});
expect(artifactPlan?.domainEvent).toMatchObject({
id: "evt_cmd_artifact_1",
eventType: "tree.subtree.moved",
payload: {
schema: "mnote.tree.domain_event",
schemaVersion: 1,
streamDelta: {
op: "move_document",
documentId: "doc_1",
parentId: "parent_1",
sortOrder: 2,
updatedAt: "2026-04-26T10:00:00Z",
},
},
});
}, 30_000);
});
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api";
import {
DocumentBridgeError,
@@ -37,6 +38,10 @@ type RustRuntimeResponse =
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
}
| RustRuntimeExecutedQuery
| {
ok: true;
artifacts: RustBridgeCommandArtifactPlan | null;
}
| {
ok: false;
error: RustRuntimeErrorPayload;
@@ -68,6 +73,88 @@ export type RustBridgeCommandPlan = {
argsJson: Record<string, unknown>;
};
export type RustTreeDomainEventPlan = {
family: "tree";
schema: "mnote.tree.domain_event";
schemaVersion: 1;
eventType: string;
streamDeltaHint?: Record<string, unknown>;
streamDelta?: RustTreeStreamDelta;
};
export type RustTreeStreamDelta =
| {
op: "noop";
}
| {
op: "upsert_document";
document: Record<string, unknown>;
}
| {
op: "upsert_documents";
upsertDocuments: Record<string, unknown>[];
}
| {
op: "remove_document";
documentId: string;
}
| {
op: "move_document";
documentId: string;
parentId: string | null;
sortOrder: number;
updatedAt?: string;
}
| {
op: "upsert_assets";
upsertAssets: Record<string, unknown>[];
};
export type RustBridgeCommandLogArtifactPlan = {
workspaceId: string;
id: string;
requestId: string;
traceId: string;
commandId: string;
commandName: string;
actorId: string;
actorType: string;
sourceChannel: string;
sourceClient: string;
status: string;
targetPageId: string | null;
targetBlockId: string | null;
payload: unknown;
payloadSummary: string;
refs: string[];
idempotencyKey: string | null;
error: string | null;
createdAt: string;
finishedAt: string | null;
};
export type RustBridgeDomainEventArtifactPlan = {
workspaceId: string;
id: string;
requestId: string;
traceId: string;
commandId: string;
commandLogId: string;
eventType: string;
aggregateType: string;
aggregateId: string;
eventVersion: number;
status: string;
actorType: string;
payload: unknown;
createdAt: string;
};
export type RustBridgeCommandArtifactPlan = {
commandLog: RustBridgeCommandLogArtifactPlan;
domainEvent: RustBridgeDomainEventArtifactPlan | null;
};
export type RustBridgeToolPlanStep = {
kind: string;
name: string;
@@ -433,6 +520,371 @@ function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string)
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
function readStringArrayArg(argsJson: Record<string, unknown>, field: string): string[] {
const value = argsJson[field];
if (!Array.isArray(value)) {
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
}
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter((item) => item.length > 0);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function readRecordField(source: unknown, field: string) {
if (!isRecord(source)) {
return null;
}
const value = source[field];
return isRecord(value) ? value : null;
}
function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string) {
const value = argsJson[field];
return isRecord(value) ? value : null;
}
function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
const value = source[field];
return typeof value === "boolean" ? value : undefined;
}
function readOptionalNonEmptyStringField(source: Record<string, unknown>, field: string) {
const value = source[field];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readNullableStringField(source: Record<string, unknown>, field: string) {
const value = source[field];
if (value === null) {
return null;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readTrimmedStringField(source: unknown, field: string) {
if (!isRecord(source)) {
return null;
}
const value = source[field];
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed || null;
}
function readOptionalParentIdFromResult(result: unknown, fallback: unknown): string | null {
if (isRecord(result) && "parent_id" in result) {
return readTrimmedStringField(result, "parent_id");
}
if (typeof fallback === "string") {
const trimmed = fallback.trim();
return trimmed || null;
}
return null;
}
function readFiniteNumberField(source: unknown, field: string) {
if (!isRecord(source)) {
return null;
}
const value = source[field];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function isTreeDeltaDocument(value: unknown): value is Record<string, unknown> {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.workspace_id === "string" &&
typeof value.access_scope === "string" &&
typeof value.is_template === "boolean" &&
typeof value.created_at === "string" &&
"title" in value &&
"parent_id" in value &&
"sort_order" in value &&
"is_starred" in value &&
"updated_at" in value
);
}
function isTreeDeltaAsset(value: unknown): value is Record<string, unknown> {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.workspace_id === "string" &&
typeof value.document_id === "string" &&
typeof value.asset_type === "string" &&
typeof value.created_at === "string" &&
typeof value.updated_at === "string" &&
"file_name" in value &&
"file_url" in value &&
"thumbnail_url" in value &&
"file_size" in value &&
"mime_type" in value
);
}
function readStreamDeltaHint(plan: RustBridgeCommandPlan) {
const hint = plan.argsJson.streamDeltaHint;
if (!isRecord(hint) || hint.family !== "tree" || typeof hint.kind !== "string") {
return null;
}
return {
kind: hint.kind,
args: isRecord(hint.args) ? hint.args : {},
};
}
export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string | null {
const eventPlan = readRustTreeDomainEventPlan(plan);
if (eventPlan) {
return eventPlan.eventType;
}
const hint = plan.argsJson.domainEventHint;
if (!isRecord(hint) || hint.family !== "tree") {
return null;
}
return readTrimmedStringField(hint, "eventType");
}
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
const eventPlan = plan.argsJson.domainEventPlan;
if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
return null;
}
if (eventPlan.schema !== "mnote.tree.domain_event" || eventPlan.schemaVersion !== 1) {
return null;
}
const eventType = readTrimmedStringField(eventPlan, "eventType");
if (!eventType) {
return null;
}
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
return {
family: "tree",
schema: "mnote.tree.domain_event",
schemaVersion: 1,
eventType,
...(streamDeltaHint ? { streamDeltaHint } : {}),
...(streamDelta ? { streamDelta } : {}),
};
}
export function materializeRustTreeDomainEventPlan(input: {
plan: RustBridgeCommandPlan;
result: unknown;
streamDelta?: RustTreeStreamDelta | null;
}): RustTreeDomainEventPlan | null {
const eventPlan = readRustTreeDomainEventPlan(input.plan);
if (!eventPlan) {
return null;
}
const streamDelta =
input.streamDelta ??
materializeRustTreeStreamDelta({
plan: input.plan,
result: input.result,
});
return {
...eventPlan,
...(streamDelta ? { streamDelta } : {}),
};
}
export function materializeRustTreeStreamDelta(input: {
plan: RustBridgeCommandPlan;
result: unknown;
}): RustTreeStreamDelta | null {
const hint = readStreamDeltaHint(input.plan);
if (!hint) {
return null;
}
if (hint.kind === "noop") {
return { op: "noop" };
}
if (hint.kind === "remove_document") {
const documentId = readTrimmedStringField(hint.args, "documentId");
return documentId ? { op: "remove_document", documentId } : null;
}
if (hint.kind === "upsert_document_patch") {
const documentId = readTrimmedStringField(hint.args, "documentId");
const patch = readRecordField(hint.args, "patch");
if (!documentId || !patch) {
return null;
}
const updatedAt = readTrimmedStringField(input.result, "updated_at");
return {
op: "upsert_document",
document: {
id: documentId,
...patch,
...(updatedAt && !("updated_at" in patch) ? { updated_at: updatedAt } : {}),
},
};
}
if (hint.kind === "document_result") {
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
const document = readRecordField(input.result, documentField);
if (!isTreeDeltaDocument(document)) {
return null;
}
return {
op: "upsert_document",
document,
};
}
if (hint.kind === "result_document") {
if (!isTreeDeltaDocument(input.result)) {
return null;
}
return {
op: "upsert_document",
document: input.result,
};
}
if (hint.kind === "copy_result") {
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
const upsertDocuments = items
.map((item) => readRecordField(item, documentField))
.filter(isTreeDeltaDocument);
return upsertDocuments.length > 0
? {
op: "upsert_documents",
upsertDocuments,
}
: null;
}
if (hint.kind === "asset_result") {
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
const upsertAssets = items.filter(isTreeDeltaAsset);
return upsertAssets.length > 0
? {
op: "upsert_assets",
upsertAssets,
}
: null;
}
if (hint.kind === "move_document") {
const documentId = readTrimmedStringField(hint.args, "documentId");
const fallbackSortOrder = readFiniteNumberField(hint.args, "sortOrder");
const sortOrder = readFiniteNumberField(input.result, "sort_order") ?? fallbackSortOrder;
if (!documentId || typeof sortOrder !== "number") {
return null;
}
const updatedAt = readTrimmedStringField(input.result, "updated_at");
return {
op: "move_document",
documentId,
parentId: readOptionalParentIdFromResult(input.result, hint.args.parentId),
sortOrder,
...(updatedAt ? { updatedAt } : {}),
};
}
return null;
}
function nowIsoForRustArtifact() {
return new Date().toISOString();
}
export async function buildRustBridgeCommandArtifactPlan(input: {
context: BridgeContext;
envelope: CommandEnvelope<unknown>;
plan: RustBridgeCommandPlan;
result: unknown;
now?: string;
}): Promise<RustBridgeCommandArtifactPlan | null> {
const response = await runRustRuntime({
kind: "commandArtifact",
context: input.context,
command: {
...input.envelope,
preflightData: input.envelope.preflightData ?? null,
},
plan: input.plan,
result: input.result,
now: input.now ?? nowIsoForRustArtifact(),
});
if (!("artifacts" in response)) {
throw new DocumentBridgeError("Rust runtime 未返回 command artifact plan", 500, "TRANSPORT_ERROR");
}
return response.artifacts;
}
export async function persistRustBridgeCommandArtifacts(input: {
client: ConvexHttpClient;
artifacts: RustBridgeCommandArtifactPlan | null;
}): Promise<void> {
const artifacts = input.artifacts;
if (!artifacts) {
return;
}
const bridgeLogsApi = api as typeof api & {
bridgeLogs: {
recordCommandLog: unknown;
recordDomainEvent: unknown;
};
};
const mutation = input.client.mutation.bind(input.client) as (
mutationReference: unknown,
args: Record<string, unknown>,
) => Promise<unknown>;
await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>);
if (artifacts.domainEvent) {
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record<string, unknown>);
}
}
export async function recordRustBridgeCommandArtifacts(input: {
context: BridgeContext;
envelope: CommandEnvelope<unknown>;
client: ConvexHttpClient;
plan: RustBridgeCommandPlan;
result: unknown;
now?: string;
}): Promise<RustBridgeCommandArtifactPlan | null> {
const artifacts = await buildRustBridgeCommandArtifactPlan({
context: input.context,
envelope: input.envelope,
plan: input.plan,
result: input.result,
now: input.now,
});
await persistRustBridgeCommandArtifacts({
client: input.client,
artifacts,
});
return artifacts;
}
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
context: BridgeContext;
envelope: QueryEnvelope<TPayload>;
@@ -653,6 +1105,12 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
mutationReference: unknown,
args: Record<string, unknown>,
) => Promise<TResult>;
const runtimeApi = api as typeof api & {
mediaAssets: {
batchCopy: unknown;
batchMove: unknown;
};
};
switch (input.plan.functionName) {
case "documents:createWithParentReference":
@@ -669,6 +1127,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
id: assertStringArg(input.plan.argsJson, "id"),
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
...("normalizedMove" in input.plan.argsJson
? { normalizedMove: input.plan.argsJson.normalizedMove }
: {}),
});
case "documents:softDelete":
return mutation(api.documents.softDelete, {
@@ -684,6 +1145,77 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
newId: assertStringArg(input.plan.argsJson, "newId"),
title: readOptionalStringArg(input.plan.argsJson, "title"),
});
case "documents:updateStats":
return mutation(api.documents.updateStats, {
id: assertStringArg(input.plan.argsJson, "id"),
wordCount: readRequiredNumberArg(input.plan.argsJson, "wordCount"),
characterCount: readRequiredNumberArg(input.plan.argsJson, "characterCount"),
blockCount: readRequiredNumberArg(input.plan.argsJson, "blockCount"),
todoTotal: readRequiredNumberArg(input.plan.argsJson, "todoTotal"),
todoDone: readRequiredNumberArg(input.plan.argsJson, "todoDone"),
});
case "documents:updateOptions": {
const options = readOptionalRecordArg(input.plan.argsJson, "options");
if (!options) {
throw new DocumentBridgeError("Rust runtime 缺少 options", 500, "TRANSPORT_ERROR");
}
return mutation(api.documents.updateOptions, {
id: assertStringArg(input.plan.argsJson, "id"),
options: {
wideLayout: readOptionalBooleanField(options, "wideLayout"),
smallText: readOptionalBooleanField(options, "smallText"),
showHeadingNumbers: readOptionalBooleanField(options, "showHeadingNumbers"),
showToc: readOptionalBooleanField(options, "showToc"),
showStructure: readOptionalBooleanField(options, "showStructure"),
protectEditing: readOptionalBooleanField(options, "protectEditing"),
showWordCount: readOptionalBooleanField(options, "showWordCount"),
collapseBacklinks: readOptionalBooleanField(options, "collapseBacklinks"),
pageFont: readOptionalNonEmptyStringField(options, "pageFont"),
layoutDensity: readOptionalNonEmptyStringField(options, "layoutDensity"),
hideChildPages: readOptionalBooleanField(options, "hideChildPages"),
showBlockRefCount: readOptionalBooleanField(options, "showBlockRefCount"),
embedDefaultBlockId: readNullableStringField(options, "embedDefaultBlockId"),
},
});
}
case "documents:copyTree": {
const rawItems = input.plan.argsJson.items;
const items = Array.isArray(rawItems)
? rawItems
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
.map((item) => ({
documentId: assertStringArg(item, "documentId"),
recursive: Boolean(item.recursive),
}))
: [];
return mutation(api.documents.copyTree, {
items,
targetParentId: readOptionalStringArg(input.plan.argsJson, "targetParentId"),
});
}
case "mediaAssets:batchCopy":
case "mediaAssets:batchMove":
return mutation(
input.plan.functionName === "mediaAssets:batchCopy"
? runtimeApi.mediaAssets.batchCopy
: runtimeApi.mediaAssets.batchMove,
{
userId: input.plan.actorId,
assetIds: readStringArrayArg(input.plan.argsJson, "assetIds"),
targetDocumentId: assertStringArg(input.plan.argsJson, "targetDocumentId"),
targetSubPath: readOptionalStringArg(input.plan.argsJson, "targetSubPath"),
resourceTransferPlan: readOptionalRecordArg(
input.plan.argsJson,
"resourceTransferPlan",
),
},
);
case "mediaAssets:replaceStorageFromUpload":
return mutation(api.mediaAssets.replaceStorageFromUpload, {
userId: assertStringArg(input.plan.argsJson, "userId"),
id: assertStringArg(input.plan.argsJson, "id"),
storageId: assertStringArg(input.plan.argsJson, "storageId") as Id<"_storage">,
});
case "documents:setTemplate":
return mutation(api.documents.setTemplate, {
id: assertStringArg(input.plan.argsJson, "id"),
@@ -4,8 +4,8 @@ import {
type BridgeContext,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import { DocumentBridgeError } from "@/lib/documents/bridge";
@@ -58,9 +58,12 @@ export async function executeSaveBridgeCommand(input: {
}
throw error;
}
await recordBridgeCommandArtifacts({
await recordRustBridgeCommandArtifacts({
client,
context: input.context,
envelope: input.envelope,
plan,
result: mutationResult,
});
return {