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
@@ -29,11 +29,14 @@ import {
assertOptionsPatch,
assertStats,
assertTitle,
buildDocumentBridgeMutationRequest,
buildDocumentCommandEnvelope,
buildDocumentQueryEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { executeMetadataBridgeCommand } from "@/lib/documents/metadata-command-adapter";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
@@ -131,6 +134,100 @@ describe("documents bridge helpers", () => {
expect(envelope.payload).toEqual({ documentId: "doc_1" });
});
it("buildDocumentBridgeMutationRequest builds title update runtime request", () => {
const envelope = buildDocumentCommandEnvelope({
name: "documents.title.update",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
});
const request = buildDocumentBridgeMutationRequest({
context: mockContext,
envelope,
mapConvexArgs: (payload) => ({
id: payload.documentId,
title: payload.title,
}),
});
expect(request.functionName).toBe("documents:updateTitle");
expect(request.workspaceId).toBe("ws_1");
expect(request.args).toEqual({
id: "doc_1",
title: "新标题",
});
expect(JSON.parse(request.payloadJson)).toEqual({
kind: "command",
name: "documents.title.update",
request_id: "req_1",
trace_id: "trace_1",
deployment_id: null,
project_id: null,
workspace_id: "ws_1",
tenant_id: null,
idempotency_key: "idem_1",
actor: {
type: "user",
id: "user_1",
session_id: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
});
});
it("buildDocumentBridgeMutationRequest builds documents.save runtime request", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
});
const request = buildDocumentBridgeMutationRequest({
context: mockContext,
envelope,
mapConvexArgs: (nextPayload) => ({
id: nextPayload.documentId,
content: nextPayload.content,
expectedRevision: nextPayload.revision,
conflictDetectionKey: nextPayload.conflictDetectionKey,
}),
});
expect(request.functionName).toBe("documents:updateContent");
expect(request.workspaceId).toBe("ws_1");
expect(request.args).toEqual({
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
});
expect(JSON.parse(request.payloadJson)).toMatchObject({
kind: "command",
name: "documents.save",
workspace_id: "ws_1",
request_id: "req_1",
trace_id: "trace_1",
});
});
it("executeMetadataBridgeCommand routes title update through adapter", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
@@ -212,4 +309,95 @@ describe("documents bridge helpers", () => {
});
expect(result.commandName).toBe("documents.options.update");
});
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");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
} as unknown as ConvexHttpClient,
});
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
const result = await executeSaveBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(mutation.mock.calls[0]?.[1]).toEqual({
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
});
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
previousBridgeArtifactCalls + 1,
);
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "documents.save",
target: { workspaceId: "ws_1", pageId: "doc_1" },
payload,
}),
});
expect(result.requestId).toBe("req_1");
expect(result.traceId).toBe("trace_1");
expect(result.commandName).toBe("documents.save");
});
it("executeSaveBridgeCommand 将冲突错误归一为 bridge rejected", async () => {
const mutation = vi.fn().mockRejectedValue(new Error("正文内容已变更,请刷新后重试"));
const { getAuthedConvexClient } = await import("@/lib/convex/route");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: {
mutation,
} as unknown as ConvexHttpClient,
});
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
});
await expect(
executeSaveBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "documents.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
}),
).rejects.toMatchObject({
name: "DocumentBridgeError",
status: 409,
code: "REJECTED",
});
});
});
+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) {
@@ -1,6 +1,11 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import type { CommandEnvelope, BridgeContext } from "@/lib/documents/bridge";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type CommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
import type { PageOptionsState } from "@/types/page-options";
@@ -35,9 +40,11 @@ export type MetadataCommandExecutionResult = {
commandName: string;
};
type MetadataMutationArgs = Record<string, unknown>;
type MetadataWriteAdapter<TPayload> = {
convexMutation: unknown;
mapConvexArgs: (payload: TPayload) => Record<string, unknown>;
mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
};
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
@@ -101,11 +108,17 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
}): 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,
});
await client.mutation(
adapter.convexMutation as Parameters<typeof client.mutation>[0],
adapter.mapConvexArgs(input.envelope.payload) as Parameters<typeof client.mutation>[1],
);
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation,
request: mutationRequest,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
@@ -0,0 +1,75 @@
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 type { DocumentSavePayload } from "@/lib/documents/save-contract";
import { DocumentBridgeError } from "@/lib/documents/bridge";
export type DocumentSaveExecutionResult = {
requestId: string;
traceId: string;
commandId: string;
commandName: string;
revision: number | null;
conflictDetectionKey: string | null;
};
export async function executeSaveBridgeCommand(input: {
context: BridgeContext;
envelope: CommandEnvelope<DocumentSavePayload>;
}): Promise<DocumentSaveExecutionResult> {
const { client } = await getAuthedConvexClient();
const mutationRequest = buildDocumentBridgeMutationRequest({
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({
client,
mutation: api.documents.updateContent,
request: mutationRequest,
});
} catch (error) {
if (error instanceof Error && /正文(内容已变更|冲突检测失败)/.test(error.message)) {
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
reason: "content_conflict",
revision: input.envelope.payload.revision,
conflictDetectionKey: input.envelope.payload.conflictDetectionKey,
});
}
throw error;
}
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision:
typeof mutationResult?.revision === "number" && Number.isInteger(mutationResult.revision)
? mutationResult.revision
: null,
conflictDetectionKey:
typeof mutationResult?.conflict_detection_key === "string" &&
mutationResult.conflict_detection_key.trim()
? mutationResult.conflict_detection_key
: null,
};
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
describe("buildDocumentSavePayload", () => {
it("统一规范 documents.save 的共享 payload", () => {
const payload = buildDocumentSavePayload({
documentId: " doc_1 ",
workspaceId: " ws_1 ",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
conflictDetectionKey: " conflict_1 ",
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: null,
blockCount: null,
});
});
it("非法 revision/conflictDetectionKey 会回退到 null", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "",
revision: -1,
content: [],
conflictDetectionKey: " ",
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: null,
revision: null,
content: [],
conflictDetectionKey: null,
snapshotCapturedAt: null,
blockCount: null,
});
});
it("保留正文快照采集元数据", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 4,
content: [{ id: "block_1" }],
conflictDetectionKey: "doc_1:4",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 4,
content: [{ id: "block_1" }],
conflictDetectionKey: "doc_1:4",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
});
});
@@ -0,0 +1,48 @@
import type { Json } from "@/types/supabase";
export type DocumentSavePayload = {
documentId: string;
workspaceId: string | null;
revision: number | null;
content: Json;
conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
};
export function buildDocumentSavePayload(input: {
documentId: string;
workspaceId?: string | null;
revision?: number | null;
content: Json;
conflictDetectionKey?: string | null;
snapshotCapturedAt?: string | null;
blockCount?: number | null;
}): DocumentSavePayload {
const revision =
typeof input.revision === "number" && Number.isInteger(input.revision) && input.revision >= 0
? input.revision
: null;
const conflictDetectionKey =
typeof input.conflictDetectionKey === "string" && input.conflictDetectionKey.trim()
? input.conflictDetectionKey.trim()
: null;
const snapshotCapturedAt =
typeof input.snapshotCapturedAt === "string" && input.snapshotCapturedAt.trim()
? input.snapshotCapturedAt.trim()
: null;
const blockCount =
typeof input.blockCount === "number" && Number.isInteger(input.blockCount) && input.blockCount >= 0
? input.blockCount
: null;
return {
documentId: input.documentId.trim(),
workspaceId: input.workspaceId?.trim() || null,
revision,
content: input.content,
conflictDetectionKey,
snapshotCapturedAt,
blockCount,
};
}
@@ -0,0 +1,56 @@
import { randomUUID } from "crypto";
export type MindmapRouteMeta = {
requestId: string;
traceId: string;
workspaceId: string | null;
documentId: string;
pageId: string;
mindmapId: string;
attachmentId: string;
ownerUserId: string;
source: "convex";
};
function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
for (const candidate of candidates) {
const value = headerList.get(candidate);
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function makeFallbackId(prefix: string): string {
return `${prefix}_${randomUUID()}`;
}
export function buildMindmapRouteMeta(
request: Request,
input: {
workspaceId?: string | null;
documentId: string;
mindmapId: string;
ownerUserId: string;
},
): MindmapRouteMeta {
const requestId =
readHeaderValue(request.headers, "x-request-id", "x-mnote-request-id") ??
makeFallbackId("req");
const traceId =
readHeaderValue(request.headers, "x-trace-id", "x-mnote-trace-id", "x-request-id") ??
makeFallbackId("trace");
return {
requestId,
traceId,
workspaceId: input.workspaceId?.trim() || null,
documentId: input.documentId,
pageId: input.documentId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
ownerUserId: input.ownerUserId,
source: "convex",
};
}
@@ -0,0 +1,93 @@
const DEFAULT_ONLYOFFICE_INTERNAL_URL = "http://127.0.0.1:8081";
const ONLYOFFICE_PROBE_PATH = "/web-apps/apps/api/documents/api.js";
const RESOLVE_CACHE_TTL_MS = 30_000;
const DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES = [
DEFAULT_ONLYOFFICE_INTERNAL_URL,
"http://127.0.0.1:8082",
"http://localhost:8081",
"http://localhost:8082",
];
let cachedOnlyOfficeInternalUrl = "";
let cachedOnlyOfficeInternalUrlAt = 0;
let pendingOnlyOfficeInternalUrl: Promise<string> | null = null;
const normalizeOnlyOfficeInternalUrl = (raw?: string | null) => {
const value = String(raw || "").trim().replace(/\/+$/, "");
if (!value) return "";
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
return url.toString().replace(/\/+$/, "");
} catch {
return "";
}
};
const getOnlyOfficeInternalUrlCandidates = () => {
const candidates: string[] = [];
const push = (value?: string | null) => {
const normalized = normalizeOnlyOfficeInternalUrl(value);
if (!normalized) return;
if (!candidates.includes(normalized)) candidates.push(normalized);
};
push(process.env.ONLYOFFICE_INTERNAL_URL);
for (const raw of String(process.env.ONLYOFFICE_INTERNAL_URL_CANDIDATES || "").split(",")) {
push(raw);
}
for (const value of DEFAULT_ONLYOFFICE_INTERNAL_URL_CANDIDATES) {
push(value);
}
return candidates.length > 0 ? candidates : [DEFAULT_ONLYOFFICE_INTERNAL_URL];
};
const probeOnlyOfficeInternalUrl = async (candidate: string) => {
try {
const probeUrl = new URL(ONLYOFFICE_PROBE_PATH, `${candidate}/`);
const response = await fetch(probeUrl, {
method: "HEAD",
redirect: "follow",
cache: "no-store",
signal: AbortSignal.timeout(2_500),
});
return response.ok;
} catch {
return false;
}
};
export const resolveOnlyOfficeInternalUrl = async () => {
const now = Date.now();
if (cachedOnlyOfficeInternalUrl && now - cachedOnlyOfficeInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
return cachedOnlyOfficeInternalUrl;
}
if (pendingOnlyOfficeInternalUrl) {
return pendingOnlyOfficeInternalUrl;
}
pendingOnlyOfficeInternalUrl = (async () => {
const candidates = getOnlyOfficeInternalUrlCandidates();
for (const candidate of candidates) {
if (await probeOnlyOfficeInternalUrl(candidate)) {
return candidate;
}
}
return candidates[0] || DEFAULT_ONLYOFFICE_INTERNAL_URL;
})();
try {
const resolved = await pendingOnlyOfficeInternalUrl;
cachedOnlyOfficeInternalUrl = resolved;
cachedOnlyOfficeInternalUrlAt = Date.now();
return resolved;
} finally {
pendingOnlyOfficeInternalUrl = null;
}
};
+25 -46
View File
@@ -1,14 +1,17 @@
import { randomUUID } from "crypto";
import type { ConvexHttpClient } from "convex/browser";
import type { FunctionReference } from "convex/server";
import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents";
import { api } from "@/lib/convex/api";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import {
mapSidebarDatasetListQueryResultToInitialData,
type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data";
import type { WorkspaceSummary } from "@/lib/workspaces";
type LoadSidebarDataFromConvexInput = {
client: ConvexHttpClient;
userId: string;
fallbackName: string;
requestedWorkspaceId?: string | null;
};
@@ -17,10 +20,20 @@ type LoadSidebarDataFromConvexResult = {
workspaces: WorkspaceSummary[];
activeWorkspaceId: string;
targetWorkspaceId: string | null;
sidebarDataset: SidebarDatasetListQueryResult | null;
sidebarInitialData: SidebarInitialData | null;
documents: DocumentRecord[];
};
const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sidebar as
| Record<string, unknown>
| undefined)?.datasetList as FunctionReference<
"query",
"public",
{ workspaceId: string },
SidebarDatasetListQueryResult
>;
export async function loadSidebarDataFromConvex(
input: LoadSidebarDataFromConvexInput,
): Promise<LoadSidebarDataFromConvexResult> {
@@ -29,9 +42,8 @@ export async function loadSidebarDataFromConvex(
workspaceIdIfCreate: randomUUID(),
});
const summaries = await input.client.query(api.workspaces.fetchWorkspaceSummaries, {});
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId;
const workspaces = bootstrap.workspaces;
const activeWorkspaceId = bootstrap.activeWorkspaceId;
const targetWorkspaceId = input.requestedWorkspaceId?.trim() || activeWorkspaceId || null;
if (!targetWorkspaceId) {
@@ -39,56 +51,23 @@ export async function loadSidebarDataFromConvex(
workspaces,
activeWorkspaceId,
targetWorkspaceId: null,
sidebarDataset: null,
sidebarInitialData: null,
documents: [],
};
}
const [documents, trashedDocuments, mindmaps, mediaAssets, trashedMediaAssets, tables] = await Promise.all([
input.client.query(api.documents.listByWorkspace, {
workspaceId: targetWorkspaceId,
}),
input.client.query(api.documents.listTrashedByWorkspace, {
workspaceId: targetWorkspaceId,
}),
input.client.query(api.mindmaps.listByWorkspace, {
workspaceId: targetWorkspaceId,
includeDeleted: true,
}),
input.client.query(api.mediaAssets.listByWorkspace, {
userId: input.userId,
workspaceId: targetWorkspaceId,
limit: 200,
}),
input.client.query(api.mediaAssets.listDeletedByWorkspace, {
userId: input.userId,
workspaceId: targetWorkspaceId,
limit: 2000,
}),
input.client.query(api.tables.listByWorkspaceForSearch, {
userId: input.userId,
workspaceId: targetWorkspaceId,
includeArchived: true,
limit: 3000,
}),
]);
const normalizedDocuments = documents as DocumentRecord[];
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
workspaceId: targetWorkspaceId,
})) as SidebarDatasetListQueryResult;
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
return {
workspaces,
workspaces: sidebarDataset.workspaces ?? workspaces,
activeWorkspaceId,
targetWorkspaceId,
sidebarInitialData: buildSidebarInitialData({
activeWorkspaceId: targetWorkspaceId,
workspaces,
documents: normalizedDocuments,
trashedDocuments,
mindmaps: mindmaps ?? [],
mediaAssets: mediaAssets ?? [],
trashedMediaAssets: trashedMediaAssets ?? [],
tables: tables ?? [],
}),
sidebarDataset,
sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
documents: normalizedDocuments,
};
}
+119 -1
View File
@@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest";
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData, extractMindmapImageAssetIdsFromData } from "@/lib/sidebar-data";
import {
buildSidebarDatasetListQueryPayload,
buildSidebarDatasetListQueryResult,
buildSidebarInitialData,
extractMindmapImageAssetIdsFromData,
mapSidebarDatasetListQueryResultToInitialData,
} from "@/lib/sidebar-data";
describe("extractMindmapImageAssetIdsFromData", () => {
it("提取导图节点里的 asset 图片引用并去重", () => {
@@ -140,4 +146,116 @@ describe("buildSidebarInitialData", () => {
expect(payload.trashedTableAssets?.map((item) => item.id)).toEqual(["table_2"]);
expect(payload.mediaAssets?.map((item) => item.id)).toEqual(["asset_file_1"]);
});
it("冻结 sidebar.dataset.list 的 Rust query 契约字段", () => {
const queryPayload = buildSidebarDatasetListQueryPayload(" ws_1 ");
expect(queryPayload).toEqual({ workspace_id: "ws_1" });
const queryResult = buildSidebarDatasetListQueryResult({
activeWorkspaceId: "ws_1",
workspaces: [
{
id: "ws_1",
name: "工作区",
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
},
],
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
],
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
expect(queryResult).toEqual({
active_workspace_id: "ws_1",
workspaces: [
{
id: "ws_1",
name: "工作区",
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
},
],
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
});
expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toEqual({
activeWorkspaceId: "ws_1",
workspaces: [
{
id: "ws_1",
name: "工作区",
type: "personal",
iconUrl: null,
memberCount: 1,
isDefault: true,
},
],
documents: [
{
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
access_scope: "private",
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
},
],
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
trashedTableAssets: [],
mindmapDocs: [],
mindmapAssets: [],
mindmapAssetChildren: {},
tableAssets: [],
mediaAssets: [],
});
});
});
+79 -12
View File
@@ -27,7 +27,7 @@ type TableRow = {
is_archived?: boolean | null;
};
type SidebarDatasetInput = {
export type SidebarDatasetInput = {
activeWorkspaceId: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
@@ -38,10 +38,37 @@ type SidebarDatasetInput = {
tables?: TableRow[] | null;
};
export type SidebarDatasetListQueryPayload = {
workspace_id: string;
};
export type SidebarDatasetListQueryResult = {
active_workspace_id: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[];
mindmap_assets: MediaAsset[];
trashed_mindmap_assets: MediaAsset[];
table_assets: MediaAsset[];
trashed_table_assets: MediaAsset[];
mindmap_docs: string[];
mindmap_asset_children: Record<string, string[]>;
};
function normalizeStringArray(values: Iterable<string>): string[] {
return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
}
export function buildSidebarDatasetListQueryPayload(
workspaceId: string,
): SidebarDatasetListQueryPayload {
return {
workspace_id: workspaceId.trim(),
};
}
export function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => {
if (!input || typeof input !== "object") return input;
@@ -154,7 +181,7 @@ function toTrashedTableAsset(row: TableRow, workspaceId: string): MediaAsset {
};
}
export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {
function deriveSidebarDataset(input: SidebarDatasetInput) {
const activeMindmaps = input.mindmaps.filter((row) => !row.deleted_at);
const trashedMindmaps = input.mindmaps.filter((row) => Boolean(row.deleted_at));
const activeTables = (input.tables ?? []).filter((row) => !row.is_archived);
@@ -169,21 +196,61 @@ export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInit
});
return {
activeWorkspaceId: input.activeWorkspaceId,
workspaces: input.workspaces,
documents: input.documents,
trashedDocuments: input.trashedDocuments,
trashedMediaAssets: [...(input.trashedMediaAssets ?? [])],
mindmapAssetChildren,
mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
trashedMindmapAssets: trashedMindmaps.map((row) =>
toTrashedMindmapAsset(row, input.activeWorkspaceId),
),
trashedTableAssets: trashedTables.map((row) =>
toTrashedTableAsset(row, input.activeWorkspaceId),
),
mindmapDocs: normalizeStringArray(activeMindmaps.map((row) => row.document_id)),
mindmapAssets: activeMindmaps.map((row) => toMindmapAsset(row, input.activeWorkspaceId)),
mindmapAssetChildren,
tableAssets: activeTables.map((row) => toTableAsset(row, input.activeWorkspaceId)),
mediaAssets: [...(input.mediaAssets ?? [])],
};
}
export function buildSidebarDatasetListQueryResult(
input: SidebarDatasetInput,
): SidebarDatasetListQueryResult {
const derived = deriveSidebarDataset(input);
return {
active_workspace_id: input.activeWorkspaceId,
workspaces: [...input.workspaces],
documents: [...input.documents],
trashed_documents: [...input.trashedDocuments],
media_assets: [...(input.mediaAssets ?? [])],
trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
mindmap_assets: derived.mindmapAssets,
trashed_mindmap_assets: derived.trashedMindmapAssets,
table_assets: derived.tableAssets,
trashed_table_assets: derived.trashedTableAssets,
mindmap_docs: derived.mindmapDocs,
mindmap_asset_children: { ...derived.mindmapAssetChildren },
};
}
export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult,
): SidebarInitialData {
return {
activeWorkspaceId: result.active_workspace_id,
workspaces: [...result.workspaces],
documents: [...result.documents],
trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets],
trashedMindmapAssets: [...result.trashed_mindmap_assets],
trashedTableAssets: [...result.trashed_table_assets],
mindmapDocs: [...result.mindmap_docs],
mindmapAssets: [...result.mindmap_assets],
mindmapAssetChildren: { ...result.mindmap_asset_children },
tableAssets: [...result.table_assets],
mediaAssets: [...result.media_assets],
};
}
export function buildSidebarInitialData(input: SidebarDatasetInput): SidebarInitialData {
const queryResult = buildSidebarDatasetListQueryResult(input);
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
}