feat: continue tree rust family cutover
- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker - route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans - preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
@@ -266,6 +266,458 @@ describe("tree-stream/server", () => {
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("单条新 domain event 携带 streamDelta 时,应直接发 delta", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:01Z",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
payload: {
|
||||
command_name: "tree.node.rename",
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "page_2",
|
||||
title: "新标题",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: "evt_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:02Z",
|
||||
});
|
||||
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: "domain_event:evt_2",
|
||||
}),
|
||||
data: {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "page_2",
|
||||
title: "新标题",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("单条资源 domain event 携带 upsert_assets 时,应直接发 delta", async () => {
|
||||
const streamDelta = {
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [
|
||||
{
|
||||
id: "asset_1",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "doc_target",
|
||||
asset_type: "file",
|
||||
file_url: "/file.pdf",
|
||||
thumbnail_url: "/file.pdf",
|
||||
file_name: "file.pdf",
|
||||
file_size: 1024,
|
||||
mime_type: "application/pdf",
|
||||
created_at: "2026-04-26T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:01Z",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
eventType: "tree.resource.moved",
|
||||
streamDelta,
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: "evt_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:02Z",
|
||||
});
|
||||
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: streamDelta,
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("单条新 domain event 缺少可识别 streamDelta 时,应保守回退 resync", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:01Z",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.node.unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: "evt_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
},
|
||||
],
|
||||
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_2" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ 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",
|
||||
cursor: JSON.stringify({
|
||||
createdAt: "2026-04-24T00:00:02Z",
|
||||
id: "domain_event:evt_2",
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("同一命令的 command log 与 domain event 同时推进且 delta 一致时,应发一次 delta", async () => {
|
||||
const streamDelta = {
|
||||
op: "upsert_document",
|
||||
document: { id: "page_2", title: "同一标题" },
|
||||
};
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [
|
||||
{
|
||||
id: "clog_2",
|
||||
command_id: "cmd_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
command_name: "tree.node.rename",
|
||||
payload: { streamDelta },
|
||||
},
|
||||
{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
command_id: "cmd_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
payload: { streamDelta },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:02Z",
|
||||
});
|
||||
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: streamDelta,
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("上一帧 cursor 来自 command log 时,应按时间排除旧 domain event 后再做双写去重", async () => {
|
||||
const streamDelta = {
|
||||
op: "move_document",
|
||||
documentId: "page_2",
|
||||
parentId: "page_1",
|
||||
sortOrder: 2,
|
||||
};
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [
|
||||
{
|
||||
id: "clog_2",
|
||||
command_id: "cmd_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
command_name: "tree.subtree.move",
|
||||
payload: { streamDelta },
|
||||
},
|
||||
{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
command_id: "cmd_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
payload: { streamDelta },
|
||||
},
|
||||
{
|
||||
event_id: "evt_1",
|
||||
command_id: "cmd_1",
|
||||
created_at: "2026-04-24T00:00:01Z",
|
||||
payload: {
|
||||
streamDelta: {
|
||||
op: "noop",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:02Z",
|
||||
});
|
||||
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: streamDelta,
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("command log 与 domain event 同时推进且 delta 不一致时,应回退 resync 避免漏发", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
command_logs: [
|
||||
{
|
||||
id: "clog_2",
|
||||
created_at: "2026-04-24T00:00:03Z",
|
||||
command_name: "tree.node.rename",
|
||||
payload: {
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: { id: "page_2", title: "命令标题" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||
],
|
||||
domain_events: [
|
||||
{
|
||||
event_id: "evt_2",
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
payload: {
|
||||
streamDelta: {
|
||||
op: "remove_document",
|
||||
documentId: "page_3",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
generated_at: "2026-04-24T00:00:03Z",
|
||||
});
|
||||
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_2" }] },
|
||||
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ 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("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
|
||||
const loadOverview = vi
|
||||
.fn()
|
||||
@@ -374,7 +826,7 @@ describe("tree-stream/server", () => {
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("move 这类附带 replace_documents 的新命令应直接发 delta,而不是触发 resync", async () => {
|
||||
it("move 这类附带 move_document 的新命令应直接发 delta,而不是触发 resync", async () => {
|
||||
const sidebarSnapshot = {
|
||||
activeWorkspaceId: "ws_1",
|
||||
workspaces: [],
|
||||
@@ -428,8 +880,11 @@ describe("tree-stream/server", () => {
|
||||
payload: {
|
||||
documentId: "page_1",
|
||||
streamDelta: {
|
||||
op: "replace_documents",
|
||||
documents: sidebarSnapshot.documents,
|
||||
op: "move_document",
|
||||
documentId: "page_1",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
updatedAt: "2026-04-24T00:01:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -460,12 +915,82 @@ describe("tree-stream/server", () => {
|
||||
payload: {
|
||||
kind: "delta",
|
||||
data: {
|
||||
op: "replace_documents",
|
||||
documents: expect.arrayContaining([
|
||||
op: "move_document",
|
||||
documentId: "page_1",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
updatedAt: "2026-04-24T00:01:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("copy 这类附带 upsert_documents 的新命令应保留批量文档字段", 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.copy",
|
||||
payload: {
|
||||
streamDelta: {
|
||||
op: "upsert_documents",
|
||||
upsertDocuments: [
|
||||
{
|
||||
id: "copy_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "Copy",
|
||||
parent_id: null,
|
||||
sort_order: 2,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-24T00:00:02Z",
|
||||
updated_at: "2026-04-24T00:00:02Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ 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[1]).toMatchObject({
|
||||
event: "delta",
|
||||
payload: {
|
||||
kind: "delta",
|
||||
data: {
|
||||
op: "upsert_documents",
|
||||
upsertDocuments: [
|
||||
expect.objectContaining({
|
||||
id: "page_1",
|
||||
id: "copy_1",
|
||||
workspace_id: "ws_1",
|
||||
}),
|
||||
]),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ export type TreeStreamEventName = "snapshot" | "delta" | "resync";
|
||||
|
||||
export interface TreeStreamCommandLogCursorRow {
|
||||
id?: string | null;
|
||||
command_id?: string | null;
|
||||
commandId?: string | null;
|
||||
created_at?: string | null;
|
||||
command_name?: string | null;
|
||||
commandName?: string | null;
|
||||
@@ -65,8 +67,11 @@ type DecodedTreeStreamCursor = {
|
||||
type TreeStreamDomainEventCursorRow = {
|
||||
id?: string | null;
|
||||
event_id?: string | null;
|
||||
command_id?: string | null;
|
||||
commandId?: string | null;
|
||||
created_at?: string | null;
|
||||
createdAt?: string | null;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
const TREE_STREAM_NOOP_COMMANDS = new Set([
|
||||
@@ -219,11 +224,110 @@ function readCommandPayloadDelta(row: TreeStreamCommandLogCursorRow): TreeStream
|
||||
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,
|
||||
parentId: typeof candidate.parentId === "string" ? candidate.parentId : candidate.parentId === null ? null : undefined,
|
||||
sortOrder:
|
||||
typeof candidate.sortOrder === "number" && Number.isFinite(candidate.sortOrder)
|
||||
? candidate.sortOrder
|
||||
: undefined,
|
||||
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
|
||||
upsertDocuments: Array.isArray(candidate.upsertDocuments)
|
||||
? (candidate.upsertDocuments as TreeStreamDeltaEvent["upsertDocuments"])
|
||||
: null,
|
||||
upsertAssets: Array.isArray(candidate.upsertAssets)
|
||||
? (candidate.upsertAssets as TreeStreamDeltaEvent["upsertAssets"])
|
||||
: null,
|
||||
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readStreamDeltaCandidate(candidate: unknown): TreeStreamDeltaEvent | null {
|
||||
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,
|
||||
parentId: typeof candidate.parentId === "string" ? candidate.parentId : candidate.parentId === null ? null : undefined,
|
||||
sortOrder:
|
||||
typeof candidate.sortOrder === "number" && Number.isFinite(candidate.sortOrder)
|
||||
? candidate.sortOrder
|
||||
: undefined,
|
||||
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
|
||||
upsertDocuments: Array.isArray(candidate.upsertDocuments)
|
||||
? (candidate.upsertDocuments as TreeStreamDeltaEvent["upsertDocuments"])
|
||||
: null,
|
||||
upsertAssets: Array.isArray(candidate.upsertAssets)
|
||||
? (candidate.upsertAssets as TreeStreamDeltaEvent["upsertAssets"])
|
||||
: null,
|
||||
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readDomainEventPayloadDelta(row: TreeStreamDomainEventCursorRow): TreeStreamDeltaEvent | null {
|
||||
if (!isRecord(row.payload)) {
|
||||
return null;
|
||||
}
|
||||
return readStreamDeltaCandidate(row.payload.streamDelta ?? row.payload.stream_delta);
|
||||
}
|
||||
|
||||
function readCommandRowCommandId(row: TreeStreamCommandLogCursorRow): string | null {
|
||||
const raw =
|
||||
typeof row.command_id === "string"
|
||||
? row.command_id
|
||||
: typeof row.commandId === "string"
|
||||
? row.commandId
|
||||
: typeof row.id === "string"
|
||||
? row.id
|
||||
: "";
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function readDomainEventCommandId(row: TreeStreamDomainEventCursorRow): string | null {
|
||||
const raw =
|
||||
typeof row.command_id === "string"
|
||||
? row.command_id
|
||||
: typeof row.commandId === "string"
|
||||
? row.commandId
|
||||
: "";
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function resolveMatchingCommandDomainEventDelta(input: {
|
||||
commandRows: TreeStreamCommandLogCursorRow[];
|
||||
domainEventRows: TreeStreamDomainEventCursorRow[];
|
||||
commandDrifted: boolean;
|
||||
domainEventDrifted: boolean;
|
||||
}): TreeStreamDeltaEvent | null {
|
||||
if (
|
||||
input.commandDrifted ||
|
||||
input.commandRows.length !== 1 ||
|
||||
input.domainEventRows.length !== 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandId = readCommandRowCommandId(input.commandRows[0] ?? {});
|
||||
const eventCommandId = readDomainEventCommandId(input.domainEventRows[0] ?? {});
|
||||
if (!commandId || commandId !== eventCommandId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandDelta = readCommandPayloadDelta(input.commandRows[0] ?? {});
|
||||
const eventDelta = readDomainEventPayloadDelta(input.domainEventRows[0] ?? {});
|
||||
if (!commandDelta || !eventDelta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(commandDelta) === JSON.stringify(eventDelta) ? eventDelta : null;
|
||||
}
|
||||
|
||||
function collectNewCommandLogs(input: {
|
||||
rows: TreeStreamCommandLogCursorRow[];
|
||||
previousCursor: string | null;
|
||||
@@ -249,6 +353,77 @@ function collectNewCommandLogs(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const newerRows = input.rows.filter((row) => {
|
||||
const createdAt = typeof row.created_at === "string" ? row.created_at.trim() : "";
|
||||
return createdAt > previousCursor.createdAt;
|
||||
});
|
||||
if (newerRows.length < input.rows.length) {
|
||||
return {
|
||||
rows: newerRows,
|
||||
drifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rows: input.rows,
|
||||
drifted: input.rows.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function collectNewDomainEvents(input: {
|
||||
rows: TreeStreamDomainEventCursorRow[];
|
||||
previousCursor: string | null;
|
||||
}) {
|
||||
const previousCursor = decodeTreeStreamCursor(input.previousCursor);
|
||||
if (!previousCursor) {
|
||||
return {
|
||||
rows: input.rows,
|
||||
drifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const previousId = previousCursor.id.startsWith("domain_event:")
|
||||
? previousCursor.id.slice("domain_event:".length)
|
||||
: previousCursor.id;
|
||||
const previousIndex = input.rows.findIndex((row) => {
|
||||
const id =
|
||||
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()
|
||||
: "";
|
||||
return id === previousId && createdAt === previousCursor.createdAt;
|
||||
});
|
||||
|
||||
if (previousIndex >= 0) {
|
||||
return {
|
||||
rows: input.rows.slice(0, previousIndex),
|
||||
drifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const newerRows = input.rows.filter((row) => {
|
||||
const createdAt =
|
||||
typeof row.created_at === "string"
|
||||
? row.created_at.trim()
|
||||
: typeof row.createdAt === "string"
|
||||
? row.createdAt.trim()
|
||||
: "";
|
||||
return createdAt > previousCursor.createdAt;
|
||||
});
|
||||
if (newerRows.length < input.rows.length) {
|
||||
return {
|
||||
rows: newerRows,
|
||||
drifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rows: input.rows,
|
||||
drifted: input.rows.length > 0,
|
||||
@@ -353,6 +528,56 @@ export async function* streamTreeFrames(
|
||||
rows,
|
||||
previousCursor: cursor,
|
||||
});
|
||||
const eventRows = Array.isArray(overview.domain_events)
|
||||
? (overview.domain_events as TreeStreamDomainEventCursorRow[])
|
||||
: [];
|
||||
const newEventRows = collectNewDomainEvents({
|
||||
rows: eventRows,
|
||||
previousCursor: cursor,
|
||||
});
|
||||
const hasNewCommandRows = newRows.rows.length > 0;
|
||||
const hasNewDomainEventRows = newEventRows.rows.length > 0;
|
||||
if (hasNewCommandRows && hasNewDomainEventRows) {
|
||||
const delta = resolveMatchingCommandDomainEventDelta({
|
||||
commandRows: newRows.rows,
|
||||
domainEventRows: newEventRows.rows,
|
||||
commandDrifted: newRows.drifted,
|
||||
domainEventDrifted: newEventRows.drifted,
|
||||
});
|
||||
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,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!newRows.drifted && newRows.rows.length === 1) {
|
||||
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
|
||||
if (delta) {
|
||||
@@ -373,6 +598,26 @@ export async function* streamTreeFrames(
|
||||
}
|
||||
}
|
||||
|
||||
if (!newEventRows.drifted && newRows.rows.length === 0 && newEventRows.rows.length === 1) {
|
||||
const delta = readDomainEventPayloadDelta(newEventRows.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;
|
||||
|
||||
|
||||
@@ -346,6 +346,84 @@ describe("tree-stream/tree-delta", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 upsert_documents 批量新增复制出的子树", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "upsert_documents",
|
||||
upsertDocuments: [
|
||||
{
|
||||
id: "copy_root",
|
||||
workspace_id: "ws_1",
|
||||
title: "Copy Root",
|
||||
parent_id: null,
|
||||
sort_order: 2,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:10:00Z",
|
||||
updated_at: "2026-04-18T00:10:00Z",
|
||||
},
|
||||
{
|
||||
id: "copy_child",
|
||||
workspace_id: "ws_1",
|
||||
title: "Copy Child",
|
||||
parent_id: "copy_root",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:10:00Z",
|
||||
updated_at: "2026-04-18T00:10:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(next.documents.map((item) => item.id)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
"copy_root",
|
||||
"copy_child",
|
||||
]);
|
||||
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
"copy_root",
|
||||
"copy_child",
|
||||
]);
|
||||
expect(
|
||||
next.kernelSidebarProjection.items.find((item) => item.nodeId === "copy_child"),
|
||||
).toMatchObject({
|
||||
parentNodeId: "copy_root",
|
||||
depth: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 move_document 细粒度更新父节点与排序字段", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "move_document",
|
||||
documentId: "child",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
updatedAt: "2026-04-18T00:10:00Z",
|
||||
});
|
||||
|
||||
expect(next.documents.find((item) => item.id === "child")).toMatchObject({
|
||||
id: "child",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
updated_at: "2026-04-18T00:10:00Z",
|
||||
});
|
||||
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
]);
|
||||
expect(
|
||||
next.kernelSidebarProjection.items.find((item) => item.nodeId === "child"),
|
||||
).toMatchObject({
|
||||
parentNodeId: null,
|
||||
depth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "replace_sidebar",
|
||||
@@ -478,4 +556,47 @@ describe("tree-stream/tree-delta", () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 upsert_assets 更新资源归属并重建 file_tree projection", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
base: fileTreeProjectionBase,
|
||||
event: {
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [
|
||||
{
|
||||
id: "asset_pdf",
|
||||
workspace_id: "ws_1",
|
||||
document_id: "child",
|
||||
asset_type: "file",
|
||||
file_url: "/manual.pdf",
|
||||
thumbnail_url: "/manual.pdf",
|
||||
bucket: null,
|
||||
storage_path: "documents/child/manual.pdf",
|
||||
file_name: "manual.pdf",
|
||||
file_size: 1024,
|
||||
mime_type: "application/pdf",
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.sidebar.mediaAssets?.find((asset) => asset.id === "asset_pdf")).toMatchObject({
|
||||
document_id: "child",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
});
|
||||
expect(
|
||||
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_pdf"),
|
||||
).toMatchObject({
|
||||
parentNodeId: "child",
|
||||
resourceMeta: expect.objectContaining({
|
||||
documentId: "child",
|
||||
resourceKind: "pdf",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ export type TreeStreamDocumentPatch =
|
||||
export type TreeStreamDeltaOp =
|
||||
| "noop"
|
||||
| "upsert_document"
|
||||
| "upsert_documents"
|
||||
| "upsert_assets"
|
||||
| "remove_document"
|
||||
| "move_document"
|
||||
| "replace_documents"
|
||||
| "replace_sidebar";
|
||||
|
||||
@@ -33,6 +36,11 @@ export type TreeStreamDeltaEvent = {
|
||||
node?: TreeStreamDocumentPatch | null;
|
||||
document?: TreeStreamDocumentPatch | null;
|
||||
documentId?: string | null;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number | null;
|
||||
updatedAt?: string | null;
|
||||
upsertDocuments?: TreeStreamDocumentPatch[] | null;
|
||||
upsertAssets?: MediaAsset[] | null;
|
||||
documents?: DocumentRecord[] | null;
|
||||
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
||||
};
|
||||
@@ -161,6 +169,42 @@ function isCompleteDocumentRecord(value: TreeStreamDocumentPatch): value is Docu
|
||||
);
|
||||
}
|
||||
|
||||
function isCompleteMediaAsset(value: unknown): value is MediaAsset {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const asset = value as MediaAsset;
|
||||
return (
|
||||
typeof asset.id === "string" &&
|
||||
typeof asset.workspace_id === "string" &&
|
||||
typeof asset.document_id === "string" &&
|
||||
typeof asset.asset_type === "string" &&
|
||||
typeof asset.created_at === "string" &&
|
||||
typeof asset.updated_at === "string" &&
|
||||
"file_name" in value &&
|
||||
"file_url" in value &&
|
||||
"thumbnail_url" in value &&
|
||||
"file_size" in value &&
|
||||
"mime_type" in value
|
||||
);
|
||||
}
|
||||
|
||||
function buildSidebarFromAssets(input: {
|
||||
base: SidebarInitialData;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): SidebarInitialData {
|
||||
const next = cloneSidebarData(input.base);
|
||||
next.mediaAssets = [...input.mediaAssets];
|
||||
next.kernelFileTreeProjection = buildKernelFileTreeProjection({
|
||||
documents: next.documents,
|
||||
mediaAssets: next.mediaAssets,
|
||||
mindmapAssets: next.mindmapAssets,
|
||||
tableAssets: next.tableAssets,
|
||||
mindmapAssetChildren: next.mindmapAssetChildren,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function deriveTreeRendererDeltaState(input: {
|
||||
projection: TreeRendererProjection;
|
||||
sidebar: SidebarInitialData;
|
||||
@@ -230,6 +274,45 @@ export function applyTreeStreamDelta(
|
||||
});
|
||||
}
|
||||
|
||||
if (event.op === "upsert_documents") {
|
||||
const patches = Array.isArray(event.upsertDocuments) ? event.upsertDocuments : [];
|
||||
if (patches.length === 0) {
|
||||
return base;
|
||||
}
|
||||
let changed = false;
|
||||
const nextDocuments = [...base.documents];
|
||||
for (const rawPatch of patches) {
|
||||
if (!rawPatch || typeof rawPatch.id !== "string" || !rawPatch.id.trim()) {
|
||||
continue;
|
||||
}
|
||||
const documentPatch = {
|
||||
...rawPatch,
|
||||
id: rawPatch.id.trim(),
|
||||
} as TreeStreamDocumentPatch;
|
||||
const existingIndex = nextDocuments.findIndex((item) => item.id === documentPatch.id);
|
||||
if (existingIndex >= 0) {
|
||||
nextDocuments[existingIndex] = {
|
||||
...nextDocuments[existingIndex],
|
||||
...documentPatch,
|
||||
};
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!isCompleteDocumentRecord(documentPatch)) {
|
||||
continue;
|
||||
}
|
||||
nextDocuments.push(documentPatch);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
return base;
|
||||
}
|
||||
return buildSidebarFromDocuments({
|
||||
base,
|
||||
documents: nextDocuments,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.op === "remove_document") {
|
||||
const documentId = normalizeDocumentId(event);
|
||||
if (!documentId) {
|
||||
@@ -252,6 +335,66 @@ export function applyTreeStreamDelta(
|
||||
});
|
||||
}
|
||||
|
||||
if (event.op === "move_document") {
|
||||
const documentId = normalizeDocumentId(event);
|
||||
if (!documentId) {
|
||||
return base;
|
||||
}
|
||||
const existingIndex = base.documents.findIndex((item) => item.id === documentId);
|
||||
if (existingIndex < 0) {
|
||||
return base;
|
||||
}
|
||||
const nextDocuments = [...base.documents];
|
||||
const existing = nextDocuments[existingIndex]!;
|
||||
nextDocuments[existingIndex] = {
|
||||
...existing,
|
||||
parent_id: "parentId" in event ? (event.parentId ?? null) : existing.parent_id,
|
||||
sort_order:
|
||||
typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder)
|
||||
? event.sortOrder
|
||||
: existing.sort_order,
|
||||
updated_at:
|
||||
typeof event.updatedAt === "string" && event.updatedAt.trim()
|
||||
? event.updatedAt.trim()
|
||||
: existing.updated_at,
|
||||
};
|
||||
return buildSidebarFromDocuments({
|
||||
base,
|
||||
documents: nextDocuments,
|
||||
});
|
||||
}
|
||||
|
||||
if (event.op === "upsert_assets") {
|
||||
const assets = Array.isArray(event.upsertAssets) ? event.upsertAssets : [];
|
||||
if (assets.length === 0) {
|
||||
return base;
|
||||
}
|
||||
let changed = false;
|
||||
const nextAssets = [...(base.mediaAssets ?? [])];
|
||||
for (const asset of assets) {
|
||||
if (!isCompleteMediaAsset(asset)) {
|
||||
continue;
|
||||
}
|
||||
const existingIndex = nextAssets.findIndex((item) => item.id === asset.id);
|
||||
if (existingIndex >= 0) {
|
||||
nextAssets[existingIndex] = {
|
||||
...nextAssets[existingIndex],
|
||||
...asset,
|
||||
};
|
||||
} else {
|
||||
nextAssets.push(asset);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
return base;
|
||||
}
|
||||
return buildSidebarFromAssets({
|
||||
base,
|
||||
mediaAssets: nextAssets,
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user