feat: 收口文档桥接与 OnlyOffice/Sidebar 回归

- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
lix-2026
2026-04-15 03:06:29 +08:00
parent 84a8454fa9
commit b33ffb99e7
51 changed files with 3260 additions and 379 deletions
+111
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "crypto";
import type { ConvexHttpClient } from "convex/browser";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { apiErrorResponse } from "@/lib/api-utils";
import type { PageOptionsState } from "@/types/page-options";
@@ -80,6 +81,28 @@ export type QueryEnvelope<T> = {
payload: T;
};
export type DocumentBridgeMutationRequest<
TArgs extends Record<string, unknown> = Record<string, unknown>,
> = {
functionName: string;
deploymentId: string | null;
projectId: string | null;
workspaceId: string | null;
requestId: string;
traceId: string;
idempotencyKey: string | null;
actorId: string;
payloadJson: string;
args: TArgs;
};
const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.title.update": "documents:updateTitle",
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
} as const satisfies Record<string, string>;
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
for (const candidate of candidates) {
const value = headerList.get(candidate);
@@ -187,6 +210,94 @@ export function buildDocumentQueryEnvelope<T>(input: { name: string; payload: T
};
}
function getDocumentBridgeMutationFunctionName(commandName: string): string {
const functionName =
DOCUMENT_BRIDGE_MUTATION_FUNCTIONS[
commandName as keyof typeof DOCUMENT_BRIDGE_MUTATION_FUNCTIONS
];
if (!functionName) {
throw new DocumentBridgeError(
`未注册文档 bridge mutation: ${commandName}`,
500,
"TRANSPORT_ERROR",
);
}
return functionName;
}
function buildDocumentCommandPayloadJson(input: {
context: BridgeContext;
commandName: string;
workspaceId: string | null;
idempotencyKey: string | null;
}): string {
return JSON.stringify({
kind: "command",
name: input.commandName,
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,
idempotency_key: input.idempotencyKey,
actor: {
type: input.context.actor.actorType,
id: input.context.actor.actorId,
session_id: input.context.actor.sessionId,
},
source: {
channel: input.context.source.channel,
client: input.context.source.client,
},
});
}
export function buildDocumentBridgeMutationRequest<
TPayload,
TArgs extends Record<string, unknown>,
>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
mapConvexArgs: (payload: TPayload) => TArgs;
}): DocumentBridgeMutationRequest<TArgs> {
const workspaceId = input.envelope.target?.workspaceId ?? input.context.workspaceId ?? null;
const idempotencyKey = input.envelope.idempotencyKey ?? input.context.idempotencyKey;
return {
functionName: getDocumentBridgeMutationFunctionName(input.envelope.name),
deploymentId: input.context.deploymentId,
projectId: input.context.projectId,
workspaceId,
requestId: input.context.requestId,
traceId: input.context.traceId,
idempotencyKey,
actorId: input.context.actor.actorId,
payloadJson: buildDocumentCommandPayloadJson({
context: input.context,
commandName: input.envelope.name,
workspaceId,
idempotencyKey,
}),
args: input.mapConvexArgs(input.envelope.payload),
};
}
export async function executeDocumentBridgeMutationRequest<
TArgs extends Record<string, unknown>,
TResult,
>(input: {
client: ConvexHttpClient;
mutation: unknown;
request: DocumentBridgeMutationRequest<TArgs>;
}): Promise<TResult> {
const mutate = input.client.mutation.bind(input.client) as (
mutation: unknown,
args: TArgs,
) => Promise<TResult>;
return mutate(input.mutation, input.request.args);
}
export function assertDocumentId(documentId: string | null | undefined): string {
const normalized = typeof documentId === "string" ? documentId.trim() : "";
if (!normalized) {