feat(tree): complete rust family runtime checklist
- add tree shell runtime artifact contracts and page/filetree/picker runtime reducers - sink tree.subtree.move write operation through Rust and formalize command event plans - harden file tree search projection contract and route thin-proxy boundaries - record completed harness tasks and move design docs into process/done
This commit is contained in:
@@ -6,9 +6,14 @@ import {
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
|
||||
import {
|
||||
recordRustBridgeCommandArtifacts as recordRustBridgeCommandArtifactsFromRuntime,
|
||||
type RustTreeDomainEventPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
export const recordRustBridgeCommandArtifacts = recordRustBridgeCommandArtifactsFromRuntime;
|
||||
|
||||
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||
export type BridgeDomainEventStatus = "pending" | "committed" | "rejected" | "failed";
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
||||
});
|
||||
|
||||
describe("executeRustBridgeMutationTransport", () => {
|
||||
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
|
||||
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
|
||||
const normalizedMove = {
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
@@ -98,6 +98,14 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const treeWriteOperation = {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: "ws_1",
|
||||
...normalizedMove,
|
||||
};
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
@@ -115,6 +123,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,6 +138,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,6 +301,79 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
});
|
||||
|
||||
describe("materializeRustTreeStreamDelta", () => {
|
||||
it("应把 Rust noop hint 物化为 noop delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "noop",
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "noop",
|
||||
});
|
||||
});
|
||||
|
||||
it("应把 Rust resync_required hint 物化为保守 resync delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
@@ -561,6 +644,82 @@ describe("readRustTreeDomainEventType", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("应保留 Rust formal domainEventPlan payload schema", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
commandId: "cmd_save",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
domainEventPlan: {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(readRustTreeDomainEventPlan(plan)).toEqual({
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
@@ -631,14 +790,28 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -661,26 +834,39 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent).toMatchObject({
|
||||
id: "evt_cmd_artifact_1",
|
||||
eventType: "tree.subtree.moved",
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
command_id: "cmd_artifact_1",
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export type RustTreeDomainEventPlan = {
|
||||
schema: "mnote.tree.domain_event";
|
||||
schemaVersion: 1;
|
||||
eventType: string;
|
||||
payload?: Record<string, unknown>;
|
||||
streamDeltaHint?: Record<string, unknown>;
|
||||
streamDelta?: RustTreeStreamDelta;
|
||||
};
|
||||
@@ -86,6 +87,13 @@ export type RustTreeStreamDelta =
|
||||
| {
|
||||
op: "noop";
|
||||
}
|
||||
| {
|
||||
op: "resync_required";
|
||||
reason?: string;
|
||||
pageId?: string;
|
||||
documentId?: string;
|
||||
blockId?: string;
|
||||
}
|
||||
| {
|
||||
op: "upsert_document";
|
||||
document: Record<string, unknown>;
|
||||
@@ -674,11 +682,13 @@ export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTr
|
||||
}
|
||||
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
|
||||
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
|
||||
const payload = readRecordField(eventPlan, "payload") ?? undefined;
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType,
|
||||
...(payload ? { payload } : {}),
|
||||
...(streamDeltaHint ? { streamDeltaHint } : {}),
|
||||
...(streamDelta ? { streamDelta } : {}),
|
||||
};
|
||||
@@ -718,6 +728,20 @@ export function materializeRustTreeStreamDelta(input: {
|
||||
return { op: "noop" };
|
||||
}
|
||||
|
||||
if (hint.kind === "resync_required") {
|
||||
const reason = readTrimmedStringField(hint.args, "reason");
|
||||
const pageId = readTrimmedStringField(hint.args, "pageId");
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
const blockId = readTrimmedStringField(hint.args, "blockId");
|
||||
return {
|
||||
op: "resync_required",
|
||||
...(reason ? { reason } : {}),
|
||||
...(pageId ? { pageId } : {}),
|
||||
...(documentId ? { documentId } : {}),
|
||||
...(blockId ? { blockId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (hint.kind === "remove_document") {
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
return documentId ? { op: "remove_document", documentId } : null;
|
||||
@@ -1130,6 +1154,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
...("normalizedMove" in input.plan.argsJson
|
||||
? { normalizedMove: input.plan.argsJson.normalizedMove }
|
||||
: {}),
|
||||
...("treeWriteOperation" in input.plan.argsJson
|
||||
? { treeWriteOperation: input.plan.argsJson.treeWriteOperation }
|
||||
: {}),
|
||||
});
|
||||
case "documents:softDelete":
|
||||
return mutation(api.documents.softDelete, {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchKernelFileTreeProjection } from "./projection-client";
|
||||
import {
|
||||
FILE_TREE_SEARCH_MAX_RESULTS,
|
||||
buildFileTreeProjectionSearchRequestMeta,
|
||||
fetchKernelFileTreeProjection,
|
||||
} from "./projection-client";
|
||||
|
||||
describe("fetchKernelFileTreeProjection", () => {
|
||||
beforeEach(() => {
|
||||
@@ -42,6 +46,59 @@ describe("fetchKernelFileTreeProjection", () => {
|
||||
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
|
||||
});
|
||||
|
||||
it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => {
|
||||
expect(FILE_TREE_SEARCH_MAX_RESULTS).toBe(80);
|
||||
expect(
|
||||
buildFileTreeProjectionSearchRequestMeta({
|
||||
workspaceId: "ws_1",
|
||||
query: " rust ",
|
||||
maxResults: 500,
|
||||
}),
|
||||
).toEqual({
|
||||
query: "rust",
|
||||
maxResults: 80,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
emptyStateText: "没有匹配结果",
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view",
|
||||
requestKey: "ws_1:rust",
|
||||
},
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
});
|
||||
});
|
||||
|
||||
it("搜索请求会把 maxResults 限制在 sidebar 使用的稳定上限内", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchKernelFileTreeProjection({
|
||||
workspaceId: "ws_1",
|
||||
query: "rust",
|
||||
maxResults: 500,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/projections/file?workspaceId=ws_1&query=rust&maxResults=80",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("失败时透出服务端错误消息", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
|
||||
export const FILE_TREE_SEARCH_MAX_RESULTS = 80;
|
||||
export const FILE_TREE_SEARCH_EMPTY_STATE_TEXT = "没有匹配结果";
|
||||
|
||||
export type FileTreeProjectionSearchRequestMeta = {
|
||||
query: string | null;
|
||||
maxResults: number | null;
|
||||
maxResultsRule: "matches_only_before_ancestor_completion";
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation";
|
||||
ordering: "kernel_file_tree_preorder";
|
||||
emptyStateText: typeof FILE_TREE_SEARCH_EMPTY_STATE_TEXT;
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view";
|
||||
requestKey: string | null;
|
||||
};
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"];
|
||||
};
|
||||
|
||||
export type FetchKernelFileTreeProjectionInput = {
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
@@ -8,6 +25,38 @@ export type FetchKernelFileTreeProjectionInput = {
|
||||
maxResults?: number | null;
|
||||
};
|
||||
|
||||
function normalizeQuery(value: string | null | undefined): string | null {
|
||||
const query = value?.trim() ?? "";
|
||||
return query.length > 0 ? query : null;
|
||||
}
|
||||
|
||||
function normalizeMaxResults(value: number | null | undefined): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(FILE_TREE_SEARCH_MAX_RESULTS, Math.max(1, Math.floor(value)));
|
||||
}
|
||||
|
||||
export function buildFileTreeProjectionSearchRequestMeta(
|
||||
input: Pick<FetchKernelFileTreeProjectionInput, "workspaceId" | "query" | "maxResults">,
|
||||
): FileTreeProjectionSearchRequestMeta {
|
||||
const query = normalizeQuery(input.query);
|
||||
const maxResults = normalizeMaxResults(input.maxResults);
|
||||
return {
|
||||
query,
|
||||
maxResults,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
emptyStateText: FILE_TREE_SEARCH_EMPTY_STATE_TEXT,
|
||||
asyncVisibility: {
|
||||
source: "kernel.project_view",
|
||||
requestKey: query ? `${input.workspaceId}:${query}` : null,
|
||||
},
|
||||
coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchKernelFileTreeProjection(
|
||||
input: FetchKernelFileTreeProjectionInput,
|
||||
): Promise<KernelFileTreeProjection> {
|
||||
@@ -20,12 +69,13 @@ export async function fetchKernelFileTreeProjection(
|
||||
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
|
||||
params.set("depth", String(input.depth));
|
||||
}
|
||||
const query = input.query?.trim();
|
||||
const query = normalizeQuery(input.query);
|
||||
if (query) {
|
||||
params.set("query", query);
|
||||
}
|
||||
if (typeof input.maxResults === "number" && Number.isFinite(input.maxResults)) {
|
||||
params.set("maxResults", String(Math.max(1, Math.floor(input.maxResults))));
|
||||
const maxResults = normalizeMaxResults(input.maxResults);
|
||||
if (maxResults != null) {
|
||||
params.set("maxResults", String(maxResults));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
|
||||
|
||||
@@ -470,6 +470,14 @@ describe("tree-stream/tree-delta", () => {
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("支持 resync_required delta 仅推进 cursor,等待后续 snapshot/resync", () => {
|
||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||
op: "resync_required",
|
||||
});
|
||||
|
||||
expect(next).toEqual(baseSidebarData);
|
||||
});
|
||||
|
||||
it("为 page_tree 定义统一 delta 应用边界,并可稳定派生页面行", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "page_tree",
|
||||
@@ -557,6 +565,59 @@ describe("tree-stream/tree-delta", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("file_tree fixture 覆盖搜索 hardening 需要的 index、asset-folder、mindmap child、book 与 pdf 资源类型", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
base: fileTreeProjectionBase,
|
||||
event: {
|
||||
op: "replace_documents",
|
||||
documents: [...fileTreeProjectionBase.documents],
|
||||
},
|
||||
});
|
||||
const rowById = new Map(next.fileTreeItems.map((item) => [item.rowId, item]));
|
||||
|
||||
expect(rowById.get("index:root")).toMatchObject({
|
||||
rowKind: "index",
|
||||
title: "index.md",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "index",
|
||||
documentId: "root",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset-folder:asset_mindmap")).toMatchObject({
|
||||
rowKind: "asset_folder",
|
||||
title: "mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "mindmap",
|
||||
assetKind: "mindmap",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_mindmap_child")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
parentNodeId: "asset-folder:asset_mindmap",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "asset",
|
||||
assetKind: "image",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_book")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "book",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "book",
|
||||
assetKind: "book",
|
||||
}),
|
||||
});
|
||||
expect(rowById.get("asset:asset_pdf")).toMatchObject({
|
||||
rowKind: "asset",
|
||||
iconHint: "pdf",
|
||||
resourceMeta: expect.objectContaining({
|
||||
resourceKind: "pdf",
|
||||
assetKind: "pdf",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("支持 upsert_assets 更新资源归属并重建 file_tree projection", () => {
|
||||
const next = applyTreeStreamDeltaToProjectionState({
|
||||
projection: "file_tree",
|
||||
|
||||
@@ -23,6 +23,7 @@ export type TreeStreamDocumentPatch =
|
||||
|
||||
export type TreeStreamDeltaOp =
|
||||
| "noop"
|
||||
| "resync_required"
|
||||
| "upsert_document"
|
||||
| "upsert_documents"
|
||||
| "upsert_assets"
|
||||
@@ -236,6 +237,10 @@ export function applyTreeStreamDelta(
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "resync_required") {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.op === "replace_sidebar" && event.sidebar) {
|
||||
if ("activeWorkspaceId" in event.sidebar) {
|
||||
return cloneSidebarData(event.sidebar as SidebarInitialData);
|
||||
|
||||
Reference in New Issue
Block a user