4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
@@ -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();