4-26 树rust-2
This commit is contained in:
@@ -23,6 +23,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client?: ConvexHttpClient;
|
||||
commandPayload?: unknown;
|
||||
status?: BridgeCommandLogStatus;
|
||||
eventStatus?: BridgeDomainEventStatus;
|
||||
error?: string | null;
|
||||
@@ -35,7 +36,7 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
const commandLogId = `clog_${input.envelope.commandId}`;
|
||||
const eventId = `evt_${input.envelope.commandId}`;
|
||||
const now = input.now ?? new Date().toISOString();
|
||||
const payload = input.envelope.payload as Record<string, unknown>;
|
||||
const payload = input.commandPayload ?? input.envelope.payload;
|
||||
const status = input.status ?? "succeeded";
|
||||
const eventStatus =
|
||||
input.eventStatus ??
|
||||
|
||||
@@ -70,6 +70,7 @@ export type CommandEnvelope<T> = {
|
||||
source: BridgeSource;
|
||||
target: BridgeTarget | null;
|
||||
payload: T;
|
||||
preflightData?: Record<string, unknown> | null;
|
||||
reason: string | null;
|
||||
refs: string[];
|
||||
dryRun: boolean;
|
||||
@@ -275,6 +276,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
|
||||
payload: T;
|
||||
context: BridgeContext;
|
||||
target?: BridgeTarget | null;
|
||||
preflightData?: Record<string, unknown> | null;
|
||||
reason?: string | null;
|
||||
refs?: string[];
|
||||
}): CommandEnvelope<T> {
|
||||
@@ -286,6 +288,7 @@ export function buildDocumentCommandEnvelope<T>(input: {
|
||||
source: input.context.source,
|
||||
target: input.target ?? null,
|
||||
payload: input.payload,
|
||||
preflightData: input.preflightData ?? null,
|
||||
reason: input.reason ?? null,
|
||||
refs: input.refs ?? [],
|
||||
dryRun: input.context.dryRun,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
compareDocumentCanonicalOrder,
|
||||
getCanonicalDocumentByBusinessId,
|
||||
getCanonicalParentDocumentId,
|
||||
pickCanonicalDocumentRecordsByBusinessId,
|
||||
pickCanonicalDocumentRecord,
|
||||
} from "../../../convex/_utils/documentRecord";
|
||||
|
||||
@@ -176,3 +177,37 @@ describe("canonical document helper", () => {
|
||||
expect(parentId).toBe("parent_alive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalDocumentRecordsByBusinessId", () => {
|
||||
it("同一 workspace 扫描结果里 business id 重复时应先折叠成 canonical 记录,供树命令写链复用", () => {
|
||||
const records = pickCanonicalDocumentRecordsByBusinessId([
|
||||
{
|
||||
_id: "doc_old",
|
||||
id: "doc_1",
|
||||
parent_id: "parent_old",
|
||||
deleted_at: null,
|
||||
created_at: "2026-04-14T00:00:00.000Z",
|
||||
updated_at: "2026-04-14T00:00:01.000Z",
|
||||
},
|
||||
{
|
||||
_id: "doc_new",
|
||||
id: "doc_1",
|
||||
parent_id: "parent_new",
|
||||
deleted_at: null,
|
||||
created_at: "2026-04-14T00:00:02.000Z",
|
||||
updated_at: "2026-04-14T00:00:03.000Z",
|
||||
},
|
||||
{
|
||||
_id: "doc_2",
|
||||
id: "doc_2",
|
||||
parent_id: null,
|
||||
deleted_at: null,
|
||||
created_at: "2026-04-14T00:00:04.000Z",
|
||||
updated_at: "2026-04-14T00:00:05.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records.map((record) => record._id)).toEqual(["doc_new", "doc_2"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildParentById, isAncestorOf } from "../../../convex/_utils/documentTree";
|
||||
|
||||
describe("document tree helper", () => {
|
||||
it("构建父链映射时保留页面与父页面关系,供树命令 legality 复用", () => {
|
||||
const parentById = buildParentById([
|
||||
{ id: "root", parent_id: null },
|
||||
{ id: "child", parent_id: "root" },
|
||||
{ id: "leaf", parent_id: "child" },
|
||||
]);
|
||||
|
||||
expect(parentById.get("root")).toBeNull();
|
||||
expect(parentById.get("child")).toBe("root");
|
||||
expect(parentById.get("leaf")).toBe("child");
|
||||
});
|
||||
|
||||
it("祖先判断应能识别多级后代,避免把页面移动到自己的子树下面", () => {
|
||||
const parentById = buildParentById([
|
||||
{ id: "root", parent_id: null },
|
||||
{ id: "child", parent_id: "root" },
|
||||
{ id: "leaf", parent_id: "child" },
|
||||
]);
|
||||
|
||||
expect(isAncestorOf("root", "leaf", parentById)).toBe(true);
|
||||
expect(isAncestorOf("child", "leaf", parentById)).toBe(true);
|
||||
expect(isAncestorOf("leaf", "root", parentById)).toBe(false);
|
||||
expect(isAncestorOf("missing", "leaf", parentById)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildParentById } from "@/lib/file-tree/dnd";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentCommandEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
@@ -42,6 +55,7 @@ vi.mock("@/lib/server/local-paths", () => ({
|
||||
}));
|
||||
|
||||
const {
|
||||
handleDocumentMoveRequest,
|
||||
normalizeDocumentCopyTreePayload,
|
||||
normalizeDocumentMovePayload,
|
||||
resolveSubtreeMoveLegality,
|
||||
@@ -128,4 +142,244 @@ describe("page-lifecycle-command-adapter", () => {
|
||||
isInvalid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("documents.move 应把 movePreflight 透传给 Rust plan", async () => {
|
||||
const client = {
|
||||
query: vi.fn(async (name: string, args: { id: string }) => {
|
||||
if (name !== "documents:getMeta") {
|
||||
return null;
|
||||
}
|
||||
if (args.id === "doc_1") {
|
||||
return {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
};
|
||||
}
|
||||
if (args.id === "child_1") {
|
||||
return {
|
||||
id: "child_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: "doc_1",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
mutation: vi.fn(),
|
||||
};
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user_1",
|
||||
email: "dev@example.com",
|
||||
name: "开发用户",
|
||||
},
|
||||
client: client as never,
|
||||
});
|
||||
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_move_1",
|
||||
traceId: "trace_move_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
});
|
||||
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.move",
|
||||
commandId: "cmd_move_1",
|
||||
functionName: "documents:move",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_move_1",
|
||||
traceId: "trace_move_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
parentId: "child_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true } as never);
|
||||
|
||||
const response = await handleDocumentMoveRequest(
|
||||
new Request("http://127.0.0.1:3000/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
position: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: expect.objectContaining({
|
||||
requestId: "req_move_1",
|
||||
}),
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.move",
|
||||
preflightData: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
sortOrder: 0,
|
||||
movePreflight: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("documents.move 失败时记录的 failure artifact 仍应保持 move envelope", async () => {
|
||||
const client = {
|
||||
query: vi.fn(async (name: string, args: { id: string }) => {
|
||||
if (name !== "documents:getMeta") {
|
||||
return null;
|
||||
}
|
||||
if (args.id === "doc_1") {
|
||||
return {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
};
|
||||
}
|
||||
if (args.id === "child_1") {
|
||||
return {
|
||||
id: "child_1",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: "doc_1",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
mutation: vi.fn(),
|
||||
};
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user_1",
|
||||
email: "dev@example.com",
|
||||
name: "开发用户",
|
||||
},
|
||||
client: client as never,
|
||||
});
|
||||
vi.mocked(buildDocumentBridgeContextWithActor).mockReturnValue({
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_move_2",
|
||||
traceId: "trace_move_2",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "wolai-frontend",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: null,
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
});
|
||||
vi.mocked(buildDocumentCommandEnvelope).mockImplementation((input: unknown) => input as never);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockRejectedValue(new Error("move failed"));
|
||||
vi.mocked(documentBridgeErrorResponse).mockImplementation((error: unknown) => ({
|
||||
body: { error: error instanceof Error ? error.message : String(error) },
|
||||
status: 500,
|
||||
}) as never);
|
||||
|
||||
const response = await handleDocumentMoveRequest(
|
||||
new Request("http://127.0.0.1:3000/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
position: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(recordBridgeCommandFailureArtifacts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.move",
|
||||
preflightData: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
parentId: "child_1",
|
||||
sortOrder: 0,
|
||||
movePreflight: {
|
||||
sourceDocument: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: null,
|
||||
},
|
||||
targetParentDocument: {
|
||||
id: "child_1",
|
||||
workspaceId: "ws_1",
|
||||
parentId: "doc_1",
|
||||
},
|
||||
targetAncestorIds: ["doc_1"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,18 @@ type MovePayload = {
|
||||
position?: number | null;
|
||||
};
|
||||
|
||||
type MovePreflightDocument = {
|
||||
id: string;
|
||||
workspaceId: string | null;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
type MovePreflightPayload = {
|
||||
sourceDocument: MovePreflightDocument;
|
||||
targetParentDocument: MovePreflightDocument | null;
|
||||
targetAncestorIds: string[];
|
||||
};
|
||||
|
||||
type DeletePayload = {
|
||||
documentId?: string | null;
|
||||
};
|
||||
@@ -136,6 +148,41 @@ export function resolveSubtreeMoveLegality(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMovePreflight(args: {
|
||||
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||
sourceDocument: MovePreflightDocument;
|
||||
targetParentId: string | null;
|
||||
}) : Promise<MovePreflightPayload> {
|
||||
let targetParentDocument: MovePreflightDocument | null = null;
|
||||
const targetAncestorIds: string[] = [];
|
||||
if (args.targetParentId) {
|
||||
const targetParentDoc = await args.client.query(api.documents.getMeta, { id: args.targetParentId });
|
||||
if (!targetParentDoc) {
|
||||
throw new Error("目标父页面不存在或无权限");
|
||||
}
|
||||
targetParentDocument = {
|
||||
id: targetParentDoc.id,
|
||||
workspaceId: trimOrNull(targetParentDoc.workspace_id),
|
||||
parentId: trimOrNull(targetParentDoc.parent_id),
|
||||
};
|
||||
let cursor = trimOrNull(targetParentDoc.parent_id);
|
||||
let depth = 0;
|
||||
while (cursor && depth < 256) {
|
||||
targetAncestorIds.push(cursor);
|
||||
const parentDoc = await args.client.query(api.documents.getMeta, { id: cursor });
|
||||
if (!parentDoc) break;
|
||||
cursor = trimOrNull(parentDoc.parent_id);
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceDocument: args.sourceDocument,
|
||||
targetParentDocument,
|
||||
targetAncestorIds,
|
||||
};
|
||||
}
|
||||
|
||||
function safeRandomId() {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
@@ -328,28 +375,47 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
|
||||
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
const requestClone = request.clone();
|
||||
let normalizedMove: NormalizedDocumentMovePayload | null = null;
|
||||
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
|
||||
let failureAuthUserId: string | null = null;
|
||||
let failureSourceDocument: MovePreflightDocument | null = null;
|
||||
try {
|
||||
const payload = (await request.json()) as MovePayload;
|
||||
const normalizedMove = normalizeDocumentMovePayload(payload);
|
||||
normalizedMove = normalizeDocumentMovePayload(payload);
|
||||
const documentId = normalizedMove.documentId;
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
failureClient = client;
|
||||
failureAuthUserId = auth.userId;
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
failureSourceDocument = {
|
||||
id: sourceDoc.id,
|
||||
workspaceId: trimOrNull(sourceDoc.workspace_id),
|
||||
parentId: trimOrNull(sourceDoc.parent_id),
|
||||
};
|
||||
|
||||
const context = await buildBridgeContext(request, sourceDoc.workspace_id ?? null, auth.userId);
|
||||
const movePreflight = await buildMovePreflight({
|
||||
client,
|
||||
sourceDocument: failureSourceDocument,
|
||||
targetParentId: normalizedMove.parentId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: normalizedMove.parentId,
|
||||
sortOrder: normalizedMove.sortOrder,
|
||||
movePreflight,
|
||||
},
|
||||
preflightData: movePreflight,
|
||||
context,
|
||||
target: {
|
||||
pageId: documentId,
|
||||
@@ -373,18 +439,49 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as DeletePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
const fallbackMove = normalizedMove
|
||||
?? normalizeDocumentMovePayload(
|
||||
(await requestClone.json().catch(() => ({}))) as MovePayload,
|
||||
);
|
||||
const documentId = fallbackMove.documentId;
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
let client = failureClient;
|
||||
let authUserId = failureAuthUserId;
|
||||
if (!client || !authUserId) {
|
||||
const authedClient = await getAuthedConvexClient();
|
||||
client = authedClient.client;
|
||||
authUserId = authedClient.auth.userId;
|
||||
}
|
||||
let sourceDocument = failureSourceDocument;
|
||||
if (!sourceDocument) {
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
throw error;
|
||||
}
|
||||
sourceDocument = {
|
||||
id: sourceDoc.id,
|
||||
workspaceId: trimOrNull(sourceDoc.workspace_id),
|
||||
parentId: trimOrNull(sourceDoc.parent_id),
|
||||
};
|
||||
}
|
||||
const context = await buildBridgeContext(request, sourceDocument.workspaceId ?? null, authUserId);
|
||||
const movePreflight = await buildMovePreflight({
|
||||
client,
|
||||
sourceDocument,
|
||||
targetParentId: fallbackMove.parentId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.delete",
|
||||
payload: { documentId },
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: fallbackMove.parentId,
|
||||
sortOrder: fallbackMove.sortOrder,
|
||||
movePreflight,
|
||||
},
|
||||
preflightData: movePreflight,
|
||||
context,
|
||||
target: {
|
||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||
workspaceId: sourceDocument.workspaceId ?? null,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ const mockContext: BridgeContext = {
|
||||
describe("page-write-command-adapter", () => {
|
||||
it("标题命令应走 rust bridge transport", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -94,7 +95,10 @@ describe("page-write-command-adapter", () => {
|
||||
title: "新标题",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await executePageWriteBridgeCommand({
|
||||
context: mockContext,
|
||||
@@ -116,6 +120,25 @@ describe("page-write-command-adapter", () => {
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
commandPayload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
title: "新标题",
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.commandName).toBe("page.head.updateTitle");
|
||||
expect(result.revision).toBeNull();
|
||||
expect(result.conflictDetectionKey).toBeNull();
|
||||
|
||||
@@ -40,6 +40,26 @@ export type PageWriteCommandExecutionResult = {
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
||||
if (!streamDelta) {
|
||||
return commandPayload;
|
||||
}
|
||||
if (isRecord(commandPayload)) {
|
||||
return {
|
||||
...commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||
return {
|
||||
id: payload.documentId,
|
||||
@@ -98,6 +118,33 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUpdatedAt(result: unknown): string | null {
|
||||
const record = isRecord(result) ? result : null;
|
||||
const updatedAt = record?.updated_at;
|
||||
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
|
||||
}
|
||||
|
||||
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
transportResult?: unknown;
|
||||
}) {
|
||||
if (input.envelope.name !== "page.head.updateTitle") {
|
||||
return input.envelope.payload;
|
||||
}
|
||||
|
||||
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
|
||||
const updatedAt = normalizeUpdatedAt(input.transportResult);
|
||||
|
||||
return attachStreamDelta(payload, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
...(updatedAt ? { updated_at: updatedAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
@@ -120,6 +167,10 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
commandPayload: buildPageWriteCommandPayload({
|
||||
envelope: input.envelope,
|
||||
transportResult,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -479,7 +479,10 @@ export async function resolveRustBridgeCommandPlan<TPayload>(input: {
|
||||
const response = await runRustRuntime({
|
||||
kind: "command",
|
||||
context: input.context,
|
||||
command: input.envelope,
|
||||
command: {
|
||||
...input.envelope,
|
||||
preflightData: input.envelope.preflightData ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!("plan" in response) || response.plan.kind !== "command") {
|
||||
|
||||
@@ -44,11 +44,11 @@ describe("tree-command-client", () => {
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/documents/delete",
|
||||
"/api/documents/restore",
|
||||
"/api/documents/purge",
|
||||
"/api/documents/embed",
|
||||
"/api/documents/copy-tree",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/documents/title",
|
||||
"/api/documents/options",
|
||||
]);
|
||||
@@ -70,6 +70,30 @@ describe("tree-command-client", () => {
|
||||
sortOrder: 0,
|
||||
workspaceId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({
|
||||
action: "archive",
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
|
||||
action: "restore",
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[5]?.[1]?.body))).toEqual({
|
||||
action: "purge",
|
||||
documentId: "doc_1",
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[6]?.[1]?.body))).toEqual({
|
||||
action: "embed",
|
||||
sourceId: "doc_1",
|
||||
targetId: "doc_2",
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[7]?.[1]?.body))).toEqual({
|
||||
action: "copy",
|
||||
targetParentId: null,
|
||||
items: [{ documentId: "doc_1", recursive: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("在后端返回错误时抛出统一异常", async () => {
|
||||
|
||||
@@ -96,7 +96,15 @@ type MoveDocumentInput = {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
type TreeCommandAction = "create" | "rename" | "move";
|
||||
type TreeCommandAction =
|
||||
| "create"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "archive"
|
||||
| "restore"
|
||||
| "purge"
|
||||
| "embed"
|
||||
| "copy";
|
||||
|
||||
type DeleteDocumentInput = {
|
||||
documentId: string;
|
||||
@@ -161,12 +169,16 @@ type TreeCommandResponse = {
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} | null;
|
||||
items?: Array<{ oldId: string; newId: string }>;
|
||||
execution?:
|
||||
| ({
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
purged?: boolean;
|
||||
} & Record<string, unknown>)
|
||||
| null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -275,52 +287,92 @@ export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ o
|
||||
export async function deleteDocumentCommand(
|
||||
input: DeleteDocumentInput,
|
||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/delete",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "archive",
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"删除失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.archive.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreDocumentCommand(
|
||||
input: RestoreDocumentInput,
|
||||
): Promise<{ success: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/restore",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "restore",
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"恢复失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.restore.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function purgeDocumentCommand(
|
||||
input: PurgeDocumentInput,
|
||||
): Promise<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ success: true; purged?: boolean; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/purge",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "purge",
|
||||
documentId: input.documentId,
|
||||
},
|
||||
"彻底删除失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
purged:
|
||||
typeof response.result?.execution?.purged === "boolean"
|
||||
? response.result.execution.purged
|
||||
: undefined,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.purge.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function embedDocumentCommand(
|
||||
input: EmbedDocumentInput,
|
||||
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/embed",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "embed",
|
||||
sourceId: input.sourceId,
|
||||
targetId: input.targetId,
|
||||
},
|
||||
"嵌入失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.embed.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function copyTreeCommand(
|
||||
@@ -329,15 +381,21 @@ export async function copyTreeCommand(
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta?: DocumentCommandMeta;
|
||||
}> {
|
||||
return postDocumentCommand<{
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta?: DocumentCommandMeta;
|
||||
}>(
|
||||
"/api/documents/copy-tree",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "copy",
|
||||
targetParentId: input.targetParentId,
|
||||
items: input.items,
|
||||
},
|
||||
"复制页面失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
items: response.result?.items ?? [],
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.copy.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||
import { buildVisibleRows } from "./rows";
|
||||
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
|
||||
import { parseFileTreeRowId } from "./types";
|
||||
|
||||
describe("buildVisibleRows", () => {
|
||||
@@ -283,6 +283,176 @@ describe("buildVisibleRows", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
|
||||
const fileTreeItems = [
|
||||
{
|
||||
rowId: "doc:page_root",
|
||||
rowKind: "document",
|
||||
nodeId: "page_root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "根页面",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 3,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:page_root",
|
||||
rowKind: "index",
|
||||
nodeId: "index:page_root",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset_folder",
|
||||
nodeId: "asset-folder:mind_1",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "mindmap",
|
||||
projectionKind: "file_tree",
|
||||
title: "头脑风暴",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "mindmap",
|
||||
documentId: "page_root",
|
||||
assetId: "mind_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "mindmap",
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:asset_child_1",
|
||||
parentNodeId: "asset-folder:mind_1",
|
||||
nodeType: "asset",
|
||||
projectionKind: "file_tree",
|
||||
title: "节点图片.png",
|
||||
depth: 2,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "asset",
|
||||
documentId: "page_root",
|
||||
assetId: "asset_child_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "image",
|
||||
iconHint: "image",
|
||||
},
|
||||
iconHint: "image",
|
||||
},
|
||||
{
|
||||
rowId: "doc:page_child",
|
||||
rowKind: "document",
|
||||
nodeId: "page_child",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "子页面",
|
||||
depth: 1,
|
||||
position: 2,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "page_child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:page_child",
|
||||
rowKind: "index",
|
||||
nodeId: "index:page_child",
|
||||
parentNodeId: "page_child",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 2,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "page_child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const filteredItems = filterKernelFileTreeProjectionItems({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
visibleDocumentIds: new Set(["page_root", "page_child"]),
|
||||
expandedDocumentIds: new Set(["page_root"]),
|
||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||
});
|
||||
|
||||
expect(filteredItems.map((item) => item.rowId)).toEqual([
|
||||
"doc:page_root",
|
||||
"index:page_root",
|
||||
"asset-folder:mind_1",
|
||||
"asset:asset_child_1",
|
||||
"doc:page_child",
|
||||
]);
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
fileTreeItems: filteredItems,
|
||||
expanded: new Set(["page_root"]),
|
||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||
});
|
||||
|
||||
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
|
||||
"doc:doc:page_root",
|
||||
"index:index:page_root",
|
||||
"asset-folder:asset-folder:mind_1",
|
||||
"asset:asset:asset_child_1",
|
||||
"doc:doc:page_child",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileTreeRowId", () => {
|
||||
|
||||
@@ -12,6 +12,40 @@ import type { MediaAsset } from "@/types/media";
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||
|
||||
export function filterKernelFileTreeProjectionItems(input: {
|
||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||
visibleDocumentIds: ReadonlySet<string>;
|
||||
expandedDocumentIds: ReadonlySet<string>;
|
||||
expandedAssetFolderIds?: ReadonlySet<string>;
|
||||
}): KernelFileTreeProjectionItem[] {
|
||||
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
|
||||
|
||||
return input.fileTreeItems.filter((item) => {
|
||||
const docId = getDocIdFromFileTreeItem(item);
|
||||
if (!input.visibleDocumentIds.has(docId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (item.rowKind) {
|
||||
case "document":
|
||||
return true;
|
||||
case "index":
|
||||
case "asset_folder":
|
||||
return input.expandedDocumentIds.has(docId);
|
||||
case "asset": {
|
||||
if (!input.expandedDocumentIds.has(docId)) {
|
||||
return false;
|
||||
}
|
||||
const parentNodeId = String(item.parentNodeId ?? "").trim();
|
||||
if (parentNodeId.startsWith("asset-folder:")) {
|
||||
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildRowsFromKernelFileTreeProjection(input: {
|
||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import {
|
||||
buildFileTreeShellRowById,
|
||||
buildFileTreeShellVisibleRowIds,
|
||||
computeFileTreeShellDeleteTargets,
|
||||
getOrderedFileTreeShellRows,
|
||||
inferFileTreeShellTargetDocumentId,
|
||||
resolveFileTreeShellMindmapTargetId,
|
||||
} from "./shell";
|
||||
|
||||
describe("file-tree shell helpers", () => {
|
||||
const nodeById = new Map<string, SidebarTreeNode>([
|
||||
[
|
||||
"doc_root",
|
||||
{
|
||||
id: "doc_root",
|
||||
title: "根页面",
|
||||
} as SidebarTreeNode,
|
||||
],
|
||||
]);
|
||||
|
||||
const assetById = new Map<string, MediaAsset>([
|
||||
[
|
||||
"mind_1",
|
||||
{
|
||||
id: "mind_1",
|
||||
document_id: "doc_root",
|
||||
workspace_id: "ws_1",
|
||||
asset_type: "mindmap",
|
||||
file_name: "mindmap.json",
|
||||
storage_path: "mindmaps/mind_1/mindmap.json",
|
||||
} as MediaAsset,
|
||||
],
|
||||
[
|
||||
"asset_child_1",
|
||||
{
|
||||
id: "asset_child_1",
|
||||
document_id: "doc_root",
|
||||
workspace_id: "ws_1",
|
||||
asset_type: "file",
|
||||
file_name: "node.png",
|
||||
storage_path: "mindmaps/mind_1/assets/node.png",
|
||||
} as MediaAsset,
|
||||
],
|
||||
[
|
||||
"pdf_1",
|
||||
{
|
||||
id: "pdf_1",
|
||||
document_id: "doc_root",
|
||||
workspace_id: "ws_1",
|
||||
asset_type: "file",
|
||||
file_name: "guide.pdf",
|
||||
storage_path: "uploads/guide.pdf",
|
||||
} as MediaAsset,
|
||||
],
|
||||
]);
|
||||
|
||||
const fileTreeItems: KernelFileTreeProjectionItem[] = [
|
||||
{
|
||||
rowId: "doc:doc_root",
|
||||
rowKind: "document",
|
||||
nodeId: "doc_root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "根页面",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 3,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:doc_root",
|
||||
rowKind: "index",
|
||||
nodeId: "index:doc_root",
|
||||
parentNodeId: "doc_root",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "doc_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset_folder",
|
||||
nodeId: "asset-folder:mind_1",
|
||||
parentNodeId: "doc_root",
|
||||
nodeType: "mindmap",
|
||||
projectionKind: "file_tree",
|
||||
title: "mindmap",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "mindmap",
|
||||
documentId: "doc_root",
|
||||
assetId: "mind_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "mindmap",
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:asset_child_1",
|
||||
parentNodeId: "asset-folder:mind_1",
|
||||
nodeType: "asset",
|
||||
projectionKind: "file_tree",
|
||||
title: "node.png",
|
||||
depth: 2,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "asset_child_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "image",
|
||||
iconHint: "image",
|
||||
},
|
||||
iconHint: "image",
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:pdf_1",
|
||||
parentNodeId: "doc_root",
|
||||
nodeType: "pdf",
|
||||
projectionKind: "file_tree",
|
||||
title: "guide.pdf",
|
||||
depth: 1,
|
||||
position: 2,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "pdf",
|
||||
documentId: "doc_root",
|
||||
assetId: "pdf_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "pdf",
|
||||
iconHint: "pdf",
|
||||
},
|
||||
iconHint: "pdf",
|
||||
},
|
||||
];
|
||||
|
||||
it("应直接从 kernel file_tree items 构造宿主 row map", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
expect(buildFileTreeShellVisibleRowIds([...fileTreeItems])).toEqual([
|
||||
"doc:doc_root",
|
||||
"index:doc_root",
|
||||
"asset-folder:mind_1",
|
||||
"asset:asset_child_1",
|
||||
"asset:pdf_1",
|
||||
]);
|
||||
|
||||
expect(rowById.get("doc:doc_root")).toMatchObject({
|
||||
rowId: "doc:doc_root",
|
||||
rowKind: "doc",
|
||||
documentId: "doc_root",
|
||||
node: expect.objectContaining({
|
||||
id: "doc_root",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset-folder:mind_1")).toMatchObject({
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset-folder",
|
||||
documentId: "doc_root",
|
||||
assetId: "mind_1",
|
||||
asset: expect.objectContaining({
|
||||
id: "mind_1",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("应正确解析 file tree shell 的导图投放目标", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset-folder:mind_1") ?? null)).toBe(
|
||||
"mind_1",
|
||||
);
|
||||
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:asset_child_1") ?? null)).toBe(
|
||||
"mind_1",
|
||||
);
|
||||
expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:pdf_1") ?? null)).toBeNull();
|
||||
});
|
||||
|
||||
it("应能仅凭 focusedRowId 从 shell row map 推回目标页面", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
expect(
|
||||
inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
rowById,
|
||||
activeDocId: null,
|
||||
}),
|
||||
).toBe("doc_root");
|
||||
expect(
|
||||
inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: "asset:pdf_1",
|
||||
rowById,
|
||||
activeDocId: null,
|
||||
}),
|
||||
).toBe("doc_root");
|
||||
expect(
|
||||
inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: null,
|
||||
rowById,
|
||||
activeDocId: "doc_root",
|
||||
}),
|
||||
).toBe("doc_root");
|
||||
});
|
||||
|
||||
it("应按当前可见顺序返回选中的 shell rows", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
expect(
|
||||
getOrderedFileTreeShellRows({
|
||||
rowIds: ["asset:pdf_1", "doc:doc_root", "missing"],
|
||||
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
|
||||
rowById,
|
||||
}).map((row) => row.rowId),
|
||||
).toEqual(["doc:doc_root", "asset:pdf_1"]);
|
||||
});
|
||||
|
||||
it("删除目标计算应跳过被父页面覆盖的附件", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
const result = computeFileTreeShellDeleteTargets({
|
||||
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
|
||||
rowById,
|
||||
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
|
||||
parentById: new Map([["doc_root", null]]),
|
||||
});
|
||||
|
||||
expect(result.docIds).toEqual(["doc_root"]);
|
||||
expect(result.assetIds).toEqual([]);
|
||||
expect(result.assetHints).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import {
|
||||
getDocIdFromFileTreeItem,
|
||||
resolveFileTreeRowAsset,
|
||||
resolveFileTreeRowNode,
|
||||
} from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { filterTopLevelDocIds } from "./dnd";
|
||||
|
||||
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
|
||||
|
||||
export type FileTreeShellRow = {
|
||||
rowId: string;
|
||||
rowKind: FileTreeShellRowKind;
|
||||
documentId: string;
|
||||
assetId: string | null;
|
||||
node: SidebarTreeNode | null;
|
||||
asset: MediaAsset | null;
|
||||
};
|
||||
|
||||
export type FileTreeShellDeleteTargets = {
|
||||
docIds: string[];
|
||||
assetIds: string[];
|
||||
assetHints: MediaAsset[];
|
||||
};
|
||||
|
||||
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
|
||||
switch (item.rowKind) {
|
||||
case "document":
|
||||
return "doc";
|
||||
case "asset_folder":
|
||||
return "asset-folder";
|
||||
default:
|
||||
return item.rowKind;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFileTreeShellVisibleRowIds(
|
||||
fileTreeItems: readonly KernelFileTreeProjectionItem[],
|
||||
): string[] {
|
||||
return fileTreeItems
|
||||
.map((item) => item.rowId)
|
||||
.filter((rowId): rowId is string => typeof rowId === "string" && rowId.trim().length > 0);
|
||||
}
|
||||
|
||||
export function buildFileTreeShellRowById(input: {
|
||||
fileTreeItems: readonly KernelFileTreeProjectionItem[];
|
||||
nodeById?: Map<string, SidebarTreeNode>;
|
||||
assetById?: Map<string, MediaAsset>;
|
||||
}): Map<string, FileTreeShellRow> {
|
||||
const rowById = new Map<string, FileTreeShellRow>();
|
||||
|
||||
input.fileTreeItems.forEach((item) => {
|
||||
const rowId = typeof item.rowId === "string" ? item.rowId.trim() : "";
|
||||
if (!rowId || rowById.has(rowId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKind = toShellRowKind(item);
|
||||
const documentId = getDocIdFromFileTreeItem(item);
|
||||
const isDocumentRow = rowKind === "doc" || rowKind === "index";
|
||||
|
||||
rowById.set(rowId, {
|
||||
rowId,
|
||||
rowKind,
|
||||
documentId,
|
||||
assetId: isDocumentRow ? null : item.resourceMeta.assetId ?? null,
|
||||
node: isDocumentRow ? resolveFileTreeRowNode(item, input.nodeById) : null,
|
||||
asset: isDocumentRow ? null : resolveFileTreeRowAsset(item, input.assetById),
|
||||
});
|
||||
});
|
||||
|
||||
return rowById;
|
||||
}
|
||||
|
||||
export function getOrderedFileTreeShellRows(input: {
|
||||
rowIds: Iterable<string>;
|
||||
visibleRowIds: readonly string[];
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
}): FileTreeShellRow[] {
|
||||
const selectedRowIds = new Set<string>();
|
||||
for (const rowId of input.rowIds) {
|
||||
if (typeof rowId !== "string" || rowId.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!input.rowById.has(rowId)) {
|
||||
continue;
|
||||
}
|
||||
selectedRowIds.add(rowId);
|
||||
}
|
||||
|
||||
return input.visibleRowIds
|
||||
.map((rowId) => (selectedRowIds.has(rowId) ? input.rowById.get(rowId) ?? null : null))
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row));
|
||||
}
|
||||
|
||||
export function extractMindmapAssetIdFromStoragePath(
|
||||
storagePath: string | null | undefined,
|
||||
): string | null {
|
||||
if (!storagePath) return null;
|
||||
const normalized = storagePath.replaceAll("\\", "/");
|
||||
|
||||
const prefix = "mindmaps/";
|
||||
if (normalized.startsWith(prefix)) {
|
||||
const rest = normalized.slice(prefix.length);
|
||||
const id = rest.split("/")[0];
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
const marker = "/mindmaps/";
|
||||
const idx = normalized.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
const rest = normalized.slice(idx + marker.length);
|
||||
const id = rest.split("/")[0];
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
export function resolveFileTreeShellMindmapTargetId(
|
||||
row: FileTreeShellRow | null,
|
||||
): string | null {
|
||||
if (!row?.asset) {
|
||||
return null;
|
||||
}
|
||||
if (row.rowKind === "asset-folder" && row.asset.asset_type === "mindmap") {
|
||||
return row.asset.id;
|
||||
}
|
||||
if (row.rowKind === "asset") {
|
||||
return extractMindmapAssetIdFromStoragePath(row.asset.storage_path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function computeFileTreeShellDeleteTargets(input: {
|
||||
visibleRowIds: readonly string[];
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
selectedRowIds: ReadonlySet<string>;
|
||||
parentById: Map<string, string | null>;
|
||||
}): FileTreeShellDeleteTargets {
|
||||
const rows = getOrderedFileTreeShellRows({
|
||||
rowIds: input.selectedRowIds,
|
||||
visibleRowIds: input.visibleRowIds,
|
||||
rowById: input.rowById,
|
||||
});
|
||||
|
||||
const docCandidates: string[] = [];
|
||||
const assetCandidates: string[] = [];
|
||||
const assetDocIdByAssetId = new Map<string, string>();
|
||||
const assetHintById = new Map<string, MediaAsset>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.rowKind === "doc" || row.rowKind === "index") {
|
||||
docCandidates.push(row.documentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
|
||||
assetCandidates.push(row.assetId);
|
||||
assetDocIdByAssetId.set(row.assetId, row.documentId);
|
||||
if (row.asset) {
|
||||
assetHintById.set(row.assetId, row.asset);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
|
||||
const docIdSet = new Set(docIds);
|
||||
const seenAssets = new Set<string>();
|
||||
const assetIds: string[] = [];
|
||||
const assetHints: MediaAsset[] = [];
|
||||
|
||||
assetCandidates.forEach((assetId) => {
|
||||
if (!assetId || seenAssets.has(assetId)) {
|
||||
return;
|
||||
}
|
||||
seenAssets.add(assetId);
|
||||
|
||||
const ownerDocId = assetDocIdByAssetId.get(assetId);
|
||||
if (ownerDocId && docIdSet.has(ownerDocId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
assetIds.push(assetId);
|
||||
const assetHint = assetHintById.get(assetId);
|
||||
if (assetHint) {
|
||||
assetHints.push(assetHint);
|
||||
}
|
||||
});
|
||||
|
||||
return { docIds, assetIds, assetHints };
|
||||
}
|
||||
|
||||
export function inferFileTreeShellTargetDocumentId(input: {
|
||||
focusedRowId: string | null;
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
activeDocId: string | null;
|
||||
}): string | null {
|
||||
if (input.focusedRowId) {
|
||||
const row = input.rowById.get(input.focusedRowId) ?? null;
|
||||
if (row?.documentId) {
|
||||
return row.documentId;
|
||||
}
|
||||
}
|
||||
return input.activeDocId || null;
|
||||
}
|
||||
@@ -31,10 +31,10 @@ describe("runtime-config public projection", () => {
|
||||
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||
});
|
||||
|
||||
it("树 renderer family 缺省时应回落到 react", () => {
|
||||
it("树 renderer family 缺省时应回落到 rust_family", () => {
|
||||
const runtime = getMnoteRuntimeConfig();
|
||||
|
||||
expect(runtime.treeRendererFamily).toBe("react");
|
||||
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export type MnoteRuntimeConfig = {
|
||||
documentEditorBlocknoteKillSwitch?: boolean;
|
||||
/**
|
||||
* 树域 renderer family 选择。
|
||||
* 说明:默认仍为 react;`rust_family` 只作为渐进切流开关,不代表已完全切主路径。
|
||||
* 说明:默认主路径已切到 rust_family;React fallback 仍作为过渡兜底保留。
|
||||
*/
|
||||
treeRendererFamily?: "react" | "rust_family";
|
||||
/**
|
||||
@@ -292,7 +292,7 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
const documentEditorBlocknoteKillSwitch =
|
||||
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
|
||||
const treeRendererFamily =
|
||||
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "react";
|
||||
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family";
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { streamTreeFrames } from "./server";
|
||||
import type { TreeStreamCommandLogCursorRow } from "./server";
|
||||
|
||||
function buildOverview(rows: TreeStreamCommandLogCursorRow[]) {
|
||||
return {
|
||||
command_logs: rows,
|
||||
domain_events: [],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function collectFrames<T>(generator: AsyncGenerator<T>) {
|
||||
const frames: T[] = [];
|
||||
for await (const frame of generator) {
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
describe("tree-stream/server", () => {
|
||||
it("workspace scope 首帧应发 snapshot,并固定 sidebar_tree + cursor", async () => {
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 0,
|
||||
loadOverview: vi.fn().mockResolvedValue(
|
||||
buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
),
|
||||
loadSnapshot: vi.fn().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||
}),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
event: "snapshot",
|
||||
payload: {
|
||||
kind: "snapshot",
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
rootNodeId: null,
|
||||
projection: "sidebar_tree",
|
||||
cursor: JSON.stringify({
|
||||
createdAt: "2026-04-24T00:00:01Z",
|
||||
id: "clog_2",
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("subtree scope 首帧应切到 subtree + page_tree", async () => {
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
rootNodeId: "page_root",
|
||||
pollMs: 1,
|
||||
maxPolls: 0,
|
||||
loadOverview: vi.fn().mockResolvedValue(
|
||||
buildOverview([{ id: "clog_2", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
),
|
||||
loadSnapshot: vi.fn().mockResolvedValue({
|
||||
requestId: "req_subtree_1",
|
||||
traceId: "trace_subtree_1",
|
||||
data: { nodes: [] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { nodes: [] } },
|
||||
}),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
event: "snapshot",
|
||||
payload: {
|
||||
kind: "snapshot",
|
||||
stream: "subtree",
|
||||
workspaceId: "ws_1",
|
||||
rootNodeId: "page_root",
|
||||
projection: "page_tree",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("检测到 cursor 之后出现新命令时,应发 resync 而不是静默结束", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([
|
||||
{ id: "clog_2", created_at: "2026-04-24T00:00:02Z" },
|
||||
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
]),
|
||||
);
|
||||
const loadSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
requestId: "req_2",
|
||||
traceId: "trace_2",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||
snapshot: {
|
||||
dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||
tree: { items: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot,
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[0]).toMatchObject({ event: "snapshot" });
|
||||
expect(frames[1]).toMatchObject({
|
||||
event: "resync",
|
||||
payload: {
|
||||
kind: "resync",
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
projection: "sidebar_tree",
|
||||
cursor: JSON.stringify({
|
||||
createdAt: "2026-04-24T00:00:02Z",
|
||||
id: "clog_2",
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("检测到带 streamDelta 的单条新命令时,应直接发 delta", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([
|
||||
{
|
||||
id: "clog_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
command_name: "tree.node.archive",
|
||||
payload: {
|
||||
documentId: "page_2",
|
||||
streamDelta: {
|
||||
op: "remove_document",
|
||||
documentId: "page_2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
]),
|
||||
);
|
||||
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||
});
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot,
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
event: "delta",
|
||||
payload: {
|
||||
kind: "delta",
|
||||
cursor: JSON.stringify({
|
||||
createdAt: "2026-04-24T00:00:02Z",
|
||||
id: "clog_2",
|
||||
}),
|
||||
data: {
|
||||
op: "remove_document",
|
||||
documentId: "page_2",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("只有 domain event 推进时,也应刷新 cursor 并触发 resync", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:02Z",
|
||||
});
|
||||
const loadSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
requestId: "req_2",
|
||||
traceId: "trace_2",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||
});
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot,
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
event: "resync",
|
||||
payload: {
|
||||
kind: "resync",
|
||||
cursor: JSON.stringify({
|
||||
createdAt: "2026-04-24T00:00:02Z",
|
||||
id: "domain_event:evt_2",
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([
|
||||
{
|
||||
id: "clog_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
command_name: "tree.subtree.move",
|
||||
payload: {
|
||||
documentId: "page_2",
|
||||
},
|
||||
},
|
||||
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
]),
|
||||
);
|
||||
const loadSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
requestId: "req_2",
|
||||
traceId: "trace_2",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||
snapshot: {
|
||||
dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }, { id: "page_2" }] },
|
||||
tree: { items: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot,
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
event: "resync",
|
||||
payload: {
|
||||
kind: "resync",
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("正文保存这类无树结构影响的命令应降级为 noop delta,而不是触发 resync", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([
|
||||
{
|
||||
id: "clog_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
command_name: "page.body.save",
|
||||
payload: {
|
||||
documentId: "page_1",
|
||||
},
|
||||
},
|
||||
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
]),
|
||||
);
|
||||
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||
});
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot,
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
event: "delta",
|
||||
payload: {
|
||||
kind: "delta",
|
||||
data: {
|
||||
op: "noop",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("move 这类附带 replace_documents 的新命令应直接发 delta,而不是触发 resync", async () => {
|
||||
const sidebarSnapshot = {
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
{
|
||||
id: "page_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 1",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
access_scope: "private",
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-24T00:00:00Z",
|
||||
updated_at: "2026-04-24T00:01:00Z",
|
||||
},
|
||||
],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelFileTreeProjection: {
|
||||
projectionId: "kernel_projection:file_tree:workspace_root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
trashedDocuments: [],
|
||||
mediaAssets: [],
|
||||
mindmapDocs: [],
|
||||
mindmapAssets: [],
|
||||
mindmapAssetChildren: {},
|
||||
tableAssets: [],
|
||||
};
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([
|
||||
{
|
||||
id: "clog_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
command_name: "tree.subtree.move",
|
||||
payload: {
|
||||
documentId: "page_1",
|
||||
streamDelta: {
|
||||
op: "replace_documents",
|
||||
documents: sidebarSnapshot.documents,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
]),
|
||||
);
|
||||
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||
});
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot,
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[1]).toMatchObject({
|
||||
event: "delta",
|
||||
payload: {
|
||||
kind: "delta",
|
||||
data: {
|
||||
op: "replace_documents",
|
||||
documents: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "page_1",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("轮询期间没有新 cursor 时,不应额外发 resync", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValue(buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]));
|
||||
|
||||
const frames = await collectFrames(
|
||||
streamTreeFrames({
|
||||
workspaceId: "ws_1",
|
||||
pollMs: 1,
|
||||
maxPolls: 1,
|
||||
loadOverview,
|
||||
loadSnapshot: vi.fn().mockResolvedValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||
}),
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0]).toMatchObject({ event: "snapshot" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import type { TreeStreamDeltaEvent } from "./tree-delta";
|
||||
|
||||
export type TreeStreamScope = "workspace" | "subtree";
|
||||
export type TreeStreamProjection = "sidebar_tree" | "page_tree";
|
||||
export type TreeStreamEventName = "snapshot" | "delta" | "resync";
|
||||
|
||||
export interface TreeStreamCommandLogCursorRow {
|
||||
id?: string | null;
|
||||
created_at?: string | null;
|
||||
command_name?: string | null;
|
||||
commandName?: string | null;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface TreeStreamOverview {
|
||||
command_logs?: TreeStreamCommandLogCursorRow[] | null;
|
||||
domain_events?: unknown[] | null;
|
||||
next_cursor?: string | null;
|
||||
has_more?: boolean | null;
|
||||
generated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TreeStreamSnapshotPayload {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
data: unknown;
|
||||
snapshot: unknown;
|
||||
}
|
||||
|
||||
export interface TreeStreamEnvelope {
|
||||
kind: TreeStreamEventName;
|
||||
stream: TreeStreamScope;
|
||||
workspaceId: string;
|
||||
rootNodeId: string | null;
|
||||
cursor: string | null;
|
||||
projection: TreeStreamProjection;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
data: unknown;
|
||||
snapshot: unknown;
|
||||
overview: TreeStreamOverview;
|
||||
}
|
||||
|
||||
export interface TreeStreamFrame {
|
||||
event: TreeStreamEventName;
|
||||
payload: TreeStreamEnvelope;
|
||||
}
|
||||
|
||||
export interface StreamTreeFramesInput {
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
initialCursor?: string | null;
|
||||
pollMs?: number;
|
||||
maxPolls?: number | null;
|
||||
loadOverview: () => Promise<TreeStreamOverview>;
|
||||
loadSnapshot: () => Promise<TreeStreamSnapshotPayload>;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
type DecodedTreeStreamCursor = {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type TreeStreamDomainEventCursorRow = {
|
||||
id?: string | null;
|
||||
event_id?: string | null;
|
||||
created_at?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
const TREE_STREAM_NOOP_COMMANDS = new Set([
|
||||
"page.body.save",
|
||||
"page.layout.updateOptions",
|
||||
"documents.stats.update",
|
||||
"blocks.patch",
|
||||
"blocks.move",
|
||||
"blocks.embed",
|
||||
]);
|
||||
|
||||
function normalizeNodeId(value: string | null | undefined) {
|
||||
const normalized = typeof value === "string" ? value.trim() : "";
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export function resolveTreeStreamContract(input: {
|
||||
rootNodeId?: string | null;
|
||||
}): {
|
||||
stream: TreeStreamScope;
|
||||
projection: TreeStreamProjection;
|
||||
rootNodeId: string | null;
|
||||
} {
|
||||
const rootNodeId = normalizeNodeId(input.rootNodeId);
|
||||
if (rootNodeId) {
|
||||
return {
|
||||
stream: "subtree",
|
||||
projection: "page_tree",
|
||||
rootNodeId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
stream: "workspace",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeTreeStreamCursor(row: TreeStreamCommandLogCursorRow | null | undefined) {
|
||||
const id = typeof row?.id === "string" ? row.id.trim() : "";
|
||||
const createdAt = typeof row?.created_at === "string" ? row.created_at.trim() : "";
|
||||
if (!id || !createdAt) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
createdAt,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeTreeStreamDomainEventCursor(
|
||||
row: TreeStreamDomainEventCursorRow | null | undefined,
|
||||
) {
|
||||
const rawId =
|
||||
typeof row?.event_id === "string"
|
||||
? row.event_id.trim()
|
||||
: typeof row?.id === "string"
|
||||
? row.id.trim()
|
||||
: "";
|
||||
const createdAt =
|
||||
typeof row?.created_at === "string"
|
||||
? row.created_at.trim()
|
||||
: typeof row?.createdAt === "string"
|
||||
? row.createdAt.trim()
|
||||
: "";
|
||||
if (!rawId || !createdAt) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
createdAt,
|
||||
id: `domain_event:${rawId}`,
|
||||
});
|
||||
}
|
||||
|
||||
function decodeTreeStreamCursor(raw: string | null | undefined): DecodedTreeStreamCursor | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
createdAt?: string | null;
|
||||
id?: string | null;
|
||||
};
|
||||
const createdAt = typeof parsed.createdAt === "string" ? parsed.createdAt.trim() : "";
|
||||
const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
|
||||
return createdAt && id ? { createdAt, id } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOverviewCursor(
|
||||
overview: TreeStreamOverview,
|
||||
fallback?: string | null,
|
||||
) {
|
||||
const commandRows = Array.isArray(overview.command_logs) ? overview.command_logs : [];
|
||||
const eventRows = Array.isArray(overview.domain_events)
|
||||
? (overview.domain_events as TreeStreamDomainEventCursorRow[])
|
||||
: [];
|
||||
const commandCursor = encodeTreeStreamCursor(commandRows[0] ?? null);
|
||||
const domainEventCursor = encodeTreeStreamDomainEventCursor(eventRows[0] ?? null);
|
||||
|
||||
if (!commandCursor) {
|
||||
return domainEventCursor ?? fallback ?? null;
|
||||
}
|
||||
if (!domainEventCursor) {
|
||||
return commandCursor ?? fallback ?? null;
|
||||
}
|
||||
|
||||
const decodedCommandCursor = decodeTreeStreamCursor(commandCursor);
|
||||
const decodedDomainEventCursor = decodeTreeStreamCursor(domainEventCursor);
|
||||
if (!decodedCommandCursor) {
|
||||
return domainEventCursor;
|
||||
}
|
||||
if (!decodedDomainEventCursor) {
|
||||
return commandCursor;
|
||||
}
|
||||
|
||||
return decodedDomainEventCursor.createdAt > decodedCommandCursor.createdAt
|
||||
? domainEventCursor
|
||||
: commandCursor;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readCommandPayloadDelta(row: TreeStreamCommandLogCursorRow): TreeStreamDeltaEvent | null {
|
||||
const commandName =
|
||||
typeof row.command_name === "string"
|
||||
? row.command_name.trim()
|
||||
: typeof row.commandName === "string"
|
||||
? row.commandName.trim()
|
||||
: "";
|
||||
if (commandName && TREE_STREAM_NOOP_COMMANDS.has(commandName)) {
|
||||
return {
|
||||
op: "noop",
|
||||
};
|
||||
}
|
||||
if (!isRecord(row.payload) || !("streamDelta" in row.payload)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = row.payload.streamDelta;
|
||||
if (!isRecord(candidate) || typeof candidate.op !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
op: candidate.op as TreeStreamDeltaEvent["op"],
|
||||
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
|
||||
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
|
||||
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
|
||||
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function collectNewCommandLogs(input: {
|
||||
rows: TreeStreamCommandLogCursorRow[];
|
||||
previousCursor: string | null;
|
||||
}) {
|
||||
const previousCursor = decodeTreeStreamCursor(input.previousCursor);
|
||||
if (!previousCursor) {
|
||||
return {
|
||||
rows: input.rows,
|
||||
drifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const previousIndex = input.rows.findIndex((row) => {
|
||||
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||
const createdAt = typeof row.created_at === "string" ? row.created_at.trim() : "";
|
||||
return id === previousCursor.id && createdAt === previousCursor.createdAt;
|
||||
});
|
||||
|
||||
if (previousIndex >= 0) {
|
||||
return {
|
||||
rows: input.rows.slice(0, previousIndex),
|
||||
drifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rows: input.rows,
|
||||
drifted: input.rows.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTreeStreamEnvelope(input: {
|
||||
kind: TreeStreamEventName;
|
||||
workspaceId: string;
|
||||
rootNodeId: string | null;
|
||||
projection: TreeStreamProjection;
|
||||
cursor: string | null;
|
||||
overview: TreeStreamOverview;
|
||||
snapshot: TreeStreamSnapshotPayload;
|
||||
}): TreeStreamEnvelope {
|
||||
return {
|
||||
kind: input.kind,
|
||||
stream: input.rootNodeId ? "subtree" : "workspace",
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: input.rootNodeId,
|
||||
cursor: input.cursor,
|
||||
projection: input.projection,
|
||||
requestId: input.snapshot.requestId,
|
||||
traceId: input.snapshot.traceId,
|
||||
data: input.snapshot.data,
|
||||
snapshot: input.snapshot.snapshot,
|
||||
overview: input.overview,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTreeStreamDeltaEnvelope(input: {
|
||||
workspaceId: string;
|
||||
rootNodeId: string | null;
|
||||
projection: TreeStreamProjection;
|
||||
cursor: string | null;
|
||||
overview: TreeStreamOverview;
|
||||
snapshot: TreeStreamSnapshotPayload;
|
||||
delta: TreeStreamDeltaEvent;
|
||||
}): TreeStreamEnvelope {
|
||||
return {
|
||||
kind: "delta",
|
||||
stream: input.rootNodeId ? "subtree" : "workspace",
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: input.rootNodeId,
|
||||
cursor: input.cursor,
|
||||
projection: input.projection,
|
||||
requestId: input.snapshot.requestId,
|
||||
traceId: input.snapshot.traceId,
|
||||
data: input.delta,
|
||||
snapshot: null,
|
||||
overview: input.overview,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultSleep(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function* streamTreeFrames(
|
||||
input: StreamTreeFramesInput,
|
||||
): AsyncGenerator<TreeStreamFrame> {
|
||||
const contract = resolveTreeStreamContract({
|
||||
rootNodeId: input.rootNodeId,
|
||||
});
|
||||
const pollMs = Math.max(250, Math.floor(input.pollMs ?? 2000));
|
||||
const maxPolls =
|
||||
typeof input.maxPolls === "number" && Number.isFinite(input.maxPolls)
|
||||
? Math.max(0, Math.floor(input.maxPolls))
|
||||
: null;
|
||||
const sleep = input.sleep ?? defaultSleep;
|
||||
|
||||
let snapshot = await input.loadSnapshot();
|
||||
let overview = await input.loadOverview();
|
||||
let cursor = resolveOverviewCursor(overview, input.initialCursor ?? null);
|
||||
|
||||
yield {
|
||||
event: "snapshot",
|
||||
payload: buildTreeStreamEnvelope({
|
||||
kind: "snapshot",
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: contract.rootNodeId,
|
||||
projection: contract.projection,
|
||||
cursor,
|
||||
overview,
|
||||
snapshot,
|
||||
}),
|
||||
};
|
||||
|
||||
let polls = 0;
|
||||
while (maxPolls === null || polls < maxPolls) {
|
||||
polls += 1;
|
||||
await sleep(pollMs);
|
||||
|
||||
overview = await input.loadOverview();
|
||||
const nextCursor = resolveOverviewCursor(overview, cursor);
|
||||
if (nextCursor === cursor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rows = Array.isArray(overview.command_logs) ? overview.command_logs : [];
|
||||
const newRows = collectNewCommandLogs({
|
||||
rows,
|
||||
previousCursor: cursor,
|
||||
});
|
||||
if (!newRows.drifted && newRows.rows.length === 1) {
|
||||
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
|
||||
if (delta) {
|
||||
cursor = nextCursor;
|
||||
yield {
|
||||
event: "delta",
|
||||
payload: buildTreeStreamDeltaEnvelope({
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: contract.rootNodeId,
|
||||
projection: contract.projection,
|
||||
cursor,
|
||||
overview,
|
||||
snapshot,
|
||||
delta,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
snapshot = await input.loadSnapshot();
|
||||
cursor = nextCursor;
|
||||
|
||||
yield {
|
||||
event: "resync",
|
||||
payload: buildTreeStreamEnvelope({
|
||||
kind: "resync",
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: contract.rootNodeId,
|
||||
projection: contract.projection,
|
||||
cursor,
|
||||
overview,
|
||||
snapshot,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { applyTreeStreamDelta } from "./tree-delta";
|
||||
import {
|
||||
applyTreeStreamDelta,
|
||||
applyTreeStreamDeltaToProjectionState,
|
||||
} from "./tree-delta";
|
||||
|
||||
const baseSidebarData: SidebarInitialData = {
|
||||
activeWorkspaceId: "ws_1",
|
||||
@@ -174,6 +177,119 @@ const baseSidebarData: SidebarInitialData = {
|
||||
mediaAssets: [],
|
||||
};
|
||||
|
||||
const fileTreeProjectionBase: SidebarInitialData = {
|
||||
...baseSidebarData,
|
||||
mediaAssets: [
|
||||
{
|
||||
id: "asset_pdf",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "root",
|
||||
asset_type: "file",
|
||||
file_url: "/manual.pdf",
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: "documents/root/manual.pdf",
|
||||
file_name: "manual.pdf",
|
||||
file_size: 1024,
|
||||
mime_type: "application/pdf",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: "2026-04-18T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "asset_book",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "root",
|
||||
asset_type: "file",
|
||||
file_url: "/novel.epub",
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: "documents/root/novel.epub",
|
||||
file_name: "novel.epub",
|
||||
file_size: 2048,
|
||||
mime_type: "application/epub+zip",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: "2026-04-18T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "asset_mindmap_child",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "root",
|
||||
asset_type: "image",
|
||||
file_url: "/mindmap/concept.png",
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: "documents/root/mindmaps/asset_mindmap/concept.png",
|
||||
file_name: "concept.png",
|
||||
file_size: 512,
|
||||
mime_type: "image/png",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: "2026-04-18T00:00:00Z",
|
||||
},
|
||||
],
|
||||
tableAssets: [
|
||||
{
|
||||
id: "asset_table",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "root",
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: "budget.luckysheet",
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: "2026-04-18T00:00:00Z",
|
||||
},
|
||||
],
|
||||
mindmapAssets: [
|
||||
{
|
||||
id: "asset_mindmap",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "root",
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: "mindmap.json",
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: "2026-04-18T00:00:00Z",
|
||||
},
|
||||
],
|
||||
mindmapAssetChildren: {
|
||||
asset_mindmap: ["asset_mindmap_child"],
|
||||
},
|
||||
};
|
||||
|
||||
describe("tree-stream/tree-delta", () => {
|
||||
it("支持 upsert_document 重建 sidebar projection", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
@@ -210,6 +326,26 @@ describe("tree-stream/tree-delta", () => {
|
||||
expect(next.kernelSidebarProjection.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("支持对已存在文档做局部 upsert patch,而不丢失原有排序字段", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "child",
|
||||
title: "Child Renamed",
|
||||
updated_at: "2026-04-18T00:05:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.documents).toHaveLength(2);
|
||||
expect(next.documents.find((item) => item.id === "child")).toMatchObject({
|
||||
id: "child",
|
||||
title: "Child Renamed",
|
||||
parent_id: "root",
|
||||
sort_order: 1,
|
||||
workspace_id: "ws_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "replace_sidebar",
|
||||
@@ -247,4 +383,99 @@ describe("tree-stream/tree-delta", () => {
|
||||
expect(next.documents).toEqual([]);
|
||||
expect(next.kernelSidebarProjection.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("支持 noop delta 仅推进 cursor,不修改当前 sidebar snapshot", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "noop",
|
||||
});
|
||||
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("为 page_tree 定义统一 delta 应用边界,并可稳定派生页面行", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "page_tree",
|
||||
base: baseSidebarData,
|
||||
event: {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "leaf",
|
||||
workspace_id: "ws_1",
|
||||
title: "Leaf",
|
||||
parent_id: "child",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.projection).toBe("page_tree");
|
||||
expect(next.documents.map((item) => item.id)).toEqual(["root", "child", "leaf"]);
|
||||
expect(next.pageTreeItems.map((item) => item.nodeId)).toEqual(["root", "child", "leaf"]);
|
||||
expect(next.pageTreeItems.find((item) => item.nodeId === "leaf")).toMatchObject({
|
||||
parentNodeId: "child",
|
||||
depth: 2,
|
||||
title: "Leaf",
|
||||
});
|
||||
});
|
||||
|
||||
it("为 file_tree 定义统一 delta 应用边界,并保留 doc/index/asset-folder/asset 行语义", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
base: fileTreeProjectionBase,
|
||||
event: {
|
||||
op: "replace_documents",
|
||||
documents: [...fileTreeProjectionBase.documents],
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.projection).toBe("file_tree");
|
||||
expect(next.fileTreeItems.map((item) => item.rowKind)).toEqual(
|
||||
expect.arrayContaining(["document", "index", "asset_folder", "asset"]),
|
||||
);
|
||||
expect(
|
||||
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_mindmap"),
|
||||
).toMatchObject({
|
||||
rowKind: "asset_folder",
|
||||
iconHint: "mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "mindmap",
|
||||
assetKind: "mindmap",
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_pdf"),
|
||||
).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "pdf",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "pdf",
|
||||
assetKind: "pdf",
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_book"),
|
||||
).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "book",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "book",
|
||||
assetKind: "book",
|
||||
}),
|
||||
});
|
||||
expect(
|
||||
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_table"),
|
||||
).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "table",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "table",
|
||||
assetKind: "table",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildKernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||
import {
|
||||
buildSidebarDatasetListQueryResult,
|
||||
mapSidebarDatasetListQueryResultToInitialData,
|
||||
} from "@/lib/sidebar-data";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import {
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
type KernelSidebarProjectionItem,
|
||||
} from "@/lib/kernel-sidebar";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import {
|
||||
buildPageTreeProjectionItems,
|
||||
type PageTreeProjectionItem,
|
||||
} from "@/lib/tree-projection";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export type TreeStreamDocumentPatch =
|
||||
Partial<DocumentRecord> & Pick<DocumentRecord, "id">;
|
||||
|
||||
export type TreeStreamDeltaOp =
|
||||
| "noop"
|
||||
| "upsert_document"
|
||||
| "remove_document"
|
||||
| "replace_documents"
|
||||
@@ -16,13 +30,24 @@ export type TreeStreamDeltaOp =
|
||||
|
||||
export type TreeStreamDeltaEvent = {
|
||||
op: TreeStreamDeltaOp;
|
||||
node?: DocumentRecord | null;
|
||||
document?: DocumentRecord | null;
|
||||
node?: TreeStreamDocumentPatch | null;
|
||||
document?: TreeStreamDocumentPatch | null;
|
||||
documentId?: string | null;
|
||||
documents?: DocumentRecord[] | null;
|
||||
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
||||
};
|
||||
|
||||
export type TreeRendererProjection = "sidebar_tree" | "page_tree" | "file_tree";
|
||||
|
||||
export type TreeRendererDeltaState = {
|
||||
projection: TreeRendererProjection;
|
||||
sidebar: SidebarInitialData;
|
||||
documents: DocumentRecord[];
|
||||
sidebarItems: KernelSidebarProjectionItem[];
|
||||
pageTreeItems: PageTreeProjectionItem[];
|
||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||
};
|
||||
|
||||
function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
|
||||
return {
|
||||
...data,
|
||||
@@ -85,12 +110,37 @@ function buildSidebarFromDocuments(input: {
|
||||
})),
|
||||
});
|
||||
|
||||
if (
|
||||
(input.base.mediaAssets?.length ?? 0) > 0 ||
|
||||
(input.base.mindmapAssets?.length ?? 0) > 0 ||
|
||||
(input.base.tableAssets?.length ?? 0) > 0 ||
|
||||
Object.keys(input.base.mindmapAssetChildren ?? {}).length > 0
|
||||
) {
|
||||
// 说明:stream delta 只替换 documents 时,仍要保留已有资源树语义;
|
||||
// 否则 mindmap 子附件会在 resync 前退化成普通 asset。
|
||||
const nextFileTreeProjection = buildKernelFileTreeProjection({
|
||||
documents: input.documents,
|
||||
mediaAssets: input.base.mediaAssets,
|
||||
mindmapAssets: input.base.mindmapAssets,
|
||||
tableAssets: input.base.tableAssets,
|
||||
mindmapAssetChildren: input.base.mindmapAssetChildren,
|
||||
});
|
||||
queryResult.kernel_file_tree_projection = nextFileTreeProjection;
|
||||
queryResult.kernelFileTreeProjection = nextFileTreeProjection;
|
||||
queryResult.mindmap_asset_children = { ...(input.base.mindmapAssetChildren ?? {}) };
|
||||
}
|
||||
|
||||
return mapSidebarDatasetListQueryResultToInitialData(queryResult);
|
||||
}
|
||||
|
||||
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): DocumentRecord | null {
|
||||
function normalizeUpsertDocument(event: TreeStreamDeltaEvent): TreeStreamDocumentPatch | null {
|
||||
const candidate = event.node ?? event.document ?? null;
|
||||
return candidate && typeof candidate === "object" ? candidate : null;
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
return typeof candidate.id === "string" && candidate.id.trim()
|
||||
? ({ ...candidate, id: candidate.id.trim() } as TreeStreamDocumentPatch)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
|
||||
@@ -98,10 +148,50 @@ function normalizeDocumentId(event: TreeStreamDeltaEvent): string | null {
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
function isCompleteDocumentRecord(value: TreeStreamDocumentPatch): value is DocumentRecord {
|
||||
return (
|
||||
typeof value.workspace_id === "string" &&
|
||||
typeof value.access_scope === "string" &&
|
||||
typeof value.is_template === "boolean" &&
|
||||
typeof value.created_at === "string" &&
|
||||
"parent_id" in value &&
|
||||
"sort_order" in value &&
|
||||
"is_starred" in value &&
|
||||
"updated_at" in value
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveTreeRendererDeltaState(input: {
|
||||
projection: TreeRendererProjection;
|
||||
sidebar: SidebarInitialData;
|
||||
}): TreeRendererDeltaState {
|
||||
const sidebar = input.sidebar;
|
||||
const pageTreeSource =
|
||||
sidebar.kernelSidebarTree.length > 0
|
||||
? sidebar.kernelSidebarTree
|
||||
: buildSidebarTreeFromKernelProjection({
|
||||
records: sidebar.documents,
|
||||
projection: sidebar.kernelSidebarProjection,
|
||||
});
|
||||
|
||||
return {
|
||||
projection: input.projection,
|
||||
sidebar,
|
||||
documents: [...sidebar.documents],
|
||||
sidebarItems: [...sidebar.kernelSidebarProjection.items],
|
||||
pageTreeItems: buildPageTreeProjectionItems(pageTreeSource),
|
||||
fileTreeItems: [...sidebar.kernelFileTreeProjection.items],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTreeStreamDelta(
|
||||
base: SidebarInitialData,
|
||||
event: TreeStreamDeltaEvent,
|
||||
): SidebarInitialData {
|
||||
if (event.op === "noop") {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "replace_sidebar" && event.sidebar) {
|
||||
if ("activeWorkspaceId" in event.sidebar) {
|
||||
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
||||
@@ -117,16 +207,22 @@ export function applyTreeStreamDelta(
|
||||
}
|
||||
|
||||
if (event.op === "upsert_document") {
|
||||
const nextDocument = normalizeUpsertDocument(event);
|
||||
if (!nextDocument) {
|
||||
const documentPatch = normalizeUpsertDocument(event);
|
||||
if (!documentPatch) {
|
||||
return base;
|
||||
}
|
||||
const nextDocuments = [...base.documents];
|
||||
const existingIndex = nextDocuments.findIndex((item) => item.id === nextDocument.id);
|
||||
const existingIndex = nextDocuments.findIndex((item) => item.id === documentPatch.id);
|
||||
if (existingIndex >= 0) {
|
||||
nextDocuments[existingIndex] = nextDocument;
|
||||
nextDocuments[existingIndex] = {
|
||||
...nextDocuments[existingIndex],
|
||||
...documentPatch,
|
||||
};
|
||||
} else {
|
||||
nextDocuments.push(nextDocument);
|
||||
if (!isCompleteDocumentRecord(documentPatch)) {
|
||||
return base;
|
||||
}
|
||||
nextDocuments.push(documentPatch);
|
||||
}
|
||||
return buildSidebarFromDocuments({
|
||||
base,
|
||||
@@ -158,3 +254,14 @@ export function applyTreeStreamDelta(
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export function applyTreeStreamDeltaToProjectionState(input: {
|
||||
projection: TreeRendererProjection;
|
||||
base: SidebarInitialData;
|
||||
event: TreeStreamDeltaEvent;
|
||||
}): TreeRendererDeltaState {
|
||||
return deriveTreeRendererDeltaState({
|
||||
projection: input.projection,
|
||||
sidebar: applyTreeStreamDelta(input.base, input.event),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,11 +50,6 @@ class MockEventSource {
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var EventSource: typeof MockEventSource;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
@@ -74,6 +69,13 @@ function buildInitialData(): SidebarInitialData {
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
kernelFileTreeProjection: {
|
||||
projectionId: "kernel_projection:file_tree:workspace_root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
@@ -86,6 +88,56 @@ function buildInitialData(): SidebarInitialData {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshotEnvelope(title = "工作区首页") {
|
||||
return {
|
||||
stream: "workspace",
|
||||
workspaceId: "ws_1",
|
||||
cursor: "evt_2",
|
||||
projection: "sidebar_tree",
|
||||
data: {
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [
|
||||
{
|
||||
id: "page_root",
|
||||
workspace_id: "ws_1",
|
||||
title,
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-24T00:00:00Z",
|
||||
updated_at: "2026-04-24T00:00:00Z",
|
||||
},
|
||||
],
|
||||
kernel_file_tree_projection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ onState }: { onState: (state: ReturnType<typeof useSidebarTreeStream>) => void }) {
|
||||
const state = useSidebarTreeStream(buildInitialData());
|
||||
|
||||
@@ -112,7 +164,7 @@ describe("useSidebarTreeStream", () => {
|
||||
},
|
||||
});
|
||||
MockEventSource.instances = [];
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||
vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -175,4 +227,74 @@ describe("useSidebarTreeStream", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("首帧前连接报错时应进入 fallback", async () => {
|
||||
await act(async () => {
|
||||
root.render(<Harness onState={onState} />);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.onerror?.();
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
data: null,
|
||||
status: "fallback",
|
||||
cursor: null,
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
expect(MockEventSource.instances[0]?.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("收到 snapshot 后连接中断也应切到 fallback,并保留最近一次 stream 数据", async () => {
|
||||
await act(async () => {
|
||||
root.render(<Harness onState={onState} />);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.emit("snapshot", buildSnapshotEnvelope("来自 stream 的标题"));
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "live",
|
||||
cursor: "evt_2",
|
||||
data: expect.objectContaining({
|
||||
documents: [expect.objectContaining({ title: "来自 stream 的标题" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
MockEventSource.instances[0]?.onerror?.();
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onState).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "fallback",
|
||||
cursor: "evt_2",
|
||||
error: expect.any(Error),
|
||||
data: expect.objectContaining({
|
||||
documents: [expect.objectContaining({ title: "来自 stream 的标题" })],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(MockEventSource.instances[0]?.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
|
||||
document: isRecord(input.document) ? (input.document as TreeStreamDeltaEvent["document"]) : null,
|
||||
documentId: typeof input.documentId === "string" ? input.documentId : null,
|
||||
documents: Array.isArray(input.documents) ? (input.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||
sidebar: isRecord(input.sidebar) ? input.sidebar : null,
|
||||
sidebar: isRecord(input.sidebar) ? (input.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
const handleError = () => {
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
status: previous.data ? "live" : "fallback",
|
||||
status: "fallback",
|
||||
error: previous.error ?? new Error("tree stream 连接失败"),
|
||||
}));
|
||||
eventSource?.close();
|
||||
|
||||
Reference in New Issue
Block a user