4-26 树rust-2
This commit is contained in:
@@ -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