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:
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
vi.mock("server-only", () => ({}), { virtual: true });
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: vi.fn(async () => ({
|
||||
userId: "user_1",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-utils", () => ({
|
||||
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
documents: {
|
||||
getMeta: "documents:getMeta",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: vi.fn(),
|
||||
resolveRustBridgeQueryPlan: vi.fn(),
|
||||
executeRustBridgeQueryTransport: vi.fn(),
|
||||
executeRustBridgeMutationTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/blocks", () => ({
|
||||
findBlockInTree: vi.fn(),
|
||||
getBlocksFromDocumentContent: vi.fn(),
|
||||
removeBlockSubtree: vi.fn(),
|
||||
replaceBlockInTree: vi.fn(),
|
||||
withBlocksWrittenBack: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("blocks/block-command-adapter", () => {
|
||||
it("executeBlockPatchCommand 应通过 Rust artifact writer 记录外层 blocks.patch", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
resolveRustBridgeQueryPlan,
|
||||
executeRustBridgeQueryTransport,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
const {
|
||||
getBlocksFromDocumentContent,
|
||||
replaceBlockInTree,
|
||||
withBlocksWrittenBack,
|
||||
} = await import("@/lib/blocks");
|
||||
const { executeBlockPatchCommand } = await import("./block-command-adapter");
|
||||
|
||||
const query = vi.fn(async (name: string) => {
|
||||
if (name === "documents:getMeta") {
|
||||
return {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
embed_default_block_id: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
query,
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
|
||||
vi.mocked(resolveRustBridgeQueryPlan).mockResolvedValue({
|
||||
kind: "query",
|
||||
queryName: "documents.content.get",
|
||||
functionName: "documents:getContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
payloadJson: "{\"kind\":\"query\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeQueryTransport).mockResolvedValue({
|
||||
content: { type: "doc" },
|
||||
revision: 7,
|
||||
conflict_detection_key: "doc_1:7",
|
||||
});
|
||||
vi.mocked(getBlocksFromDocumentContent).mockReturnValue([{ id: "blk_1" }] as never);
|
||||
vi.mocked(replaceBlockInTree).mockReturnValue({
|
||||
ok: true,
|
||||
nextBlocks: [{ id: "blk_1", type: "paragraph" }],
|
||||
} as never);
|
||||
vi.mocked(withBlocksWrittenBack).mockReturnValue({
|
||||
type: "doc",
|
||||
content: [{ id: "blk_1", type: "paragraph" }],
|
||||
} as never);
|
||||
vi.mocked(resolveRustBridgeCommandPlan)
|
||||
.mockResolvedValueOnce({
|
||||
kind: "command",
|
||||
commandName: "blocks.patch",
|
||||
commandId: "cmd_block_patch_1",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
blockId: "blk_1",
|
||||
nextBlock: { id: "blk_1", type: "paragraph" },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
kind: "command",
|
||||
commandName: "documents.save",
|
||||
commandId: "cmd_save_1",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
content: { type: "doc", content: [{ id: "blk_1", type: "paragraph" }] },
|
||||
expectedRevision: 7,
|
||||
conflictDetectionKey: "doc_1:7",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
});
|
||||
|
||||
const result = await executeBlockPatchCommand({
|
||||
request: new Request("http://127.0.0.1:3000/api/blocks/patch", {
|
||||
method: "POST",
|
||||
}),
|
||||
sourceDocumentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
blockId: "blk_1",
|
||||
nextBlock: {
|
||||
id: "blk_1",
|
||||
type: "paragraph",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.commandName).toBe("blocks.patch");
|
||||
expect(result.result).toEqual({
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
});
|
||||
expect(vi.mocked(resolveRustBridgeCommandPlan).mock.calls).toHaveLength(2);
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(1);
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
envelope: expect.objectContaining({
|
||||
name: "blocks.patch",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "blocks.patch",
|
||||
}),
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import { recordRustBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
executeRustBridgeQueryTransport,
|
||||
@@ -143,16 +143,19 @@ async function resolveBlockCommandEnvelope<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}) {
|
||||
await resolveRustBridgeCommandPlan({
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
} satisfies BlockCommandMeta;
|
||||
meta: {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
} satisfies BlockCommandMeta,
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeDocumentSaveTransport(input: {
|
||||
@@ -196,10 +199,12 @@ async function executeDocumentSaveTransport(input: {
|
||||
plan,
|
||||
});
|
||||
if (input.recordArtifacts !== false) {
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client: input.client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -290,7 +295,7 @@ export async function executeBlockPatchCommand(input: {
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
const meta = await resolveBlockCommandEnvelope({
|
||||
const { meta, plan } = await resolveBlockCommandEnvelope({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
@@ -304,10 +309,12 @@ export async function executeBlockPatchCommand(input: {
|
||||
conflictDetectionKey: state.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result: save.result,
|
||||
});
|
||||
return {
|
||||
...meta,
|
||||
@@ -373,7 +380,7 @@ export async function executeBlockMoveCommand(input: {
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
const meta = await resolveBlockCommandEnvelope({
|
||||
const { meta, plan } = await resolveBlockCommandEnvelope({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
@@ -397,18 +404,21 @@ export async function executeBlockMoveCommand(input: {
|
||||
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
const moveResult = {
|
||||
ok: true,
|
||||
sourceRevision: sourceSave.result.revision ?? null,
|
||||
targetRevision: targetSave.result.revision ?? null,
|
||||
};
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result: moveResult,
|
||||
});
|
||||
return {
|
||||
...meta,
|
||||
result: {
|
||||
ok: true,
|
||||
sourceRevision: sourceSave.result.revision ?? null,
|
||||
targetRevision: targetSave.result.revision ?? null,
|
||||
},
|
||||
result: moveResult,
|
||||
} satisfies BlockCommandResult<{
|
||||
ok: boolean;
|
||||
sourceRevision: number | null;
|
||||
@@ -487,7 +497,7 @@ export async function executeBlockEmbedCommand(input: {
|
||||
blockId,
|
||||
},
|
||||
});
|
||||
const meta = await resolveBlockCommandEnvelope({
|
||||
const { meta, plan } = await resolveBlockCommandEnvelope({
|
||||
context,
|
||||
envelope,
|
||||
});
|
||||
@@ -501,18 +511,21 @@ export async function executeBlockEmbedCommand(input: {
|
||||
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||
recordArtifacts: false,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
const embedResult = {
|
||||
ok: true,
|
||||
revision: save.result.revision ?? null,
|
||||
referenceBlockId: String(referenceBlock.id),
|
||||
};
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result: embedResult,
|
||||
});
|
||||
return {
|
||||
...meta,
|
||||
result: {
|
||||
ok: true,
|
||||
revision: save.result.revision ?? null,
|
||||
referenceBlockId: String(referenceBlock.id),
|
||||
},
|
||||
result: embedResult,
|
||||
} satisfies BlockCommandResult<{
|
||||
ok: boolean;
|
||||
revision: number | null;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: vi.fn(async () => ({
|
||||
userId: "user_1",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-utils", () => ({
|
||||
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||
message,
|
||||
status,
|
||||
details,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
documents: {
|
||||
getContent: "documents:getContent",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeCommandPlan: vi.fn(),
|
||||
resolveRustBridgeQueryPlan: vi.fn(),
|
||||
executeRustBridgeQueryTransport: vi.fn(),
|
||||
executeRustBridgeMutationTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/document-content", () => ({
|
||||
extractBlocksFromContent: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("documents/block-command-adapter", () => {
|
||||
it("executeBlockPatchBridgeCommand 应改走 Rust artifact writer", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
const { extractBlocksFromContent } = await import("@/lib/document-content");
|
||||
const { executeBlockPatchBridgeCommand } = await import("./block-command-adapter");
|
||||
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
content: { type: "doc" },
|
||||
}),
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(extractBlocksFromContent).mockReturnValue([
|
||||
{
|
||||
id: "blk_1",
|
||||
type: "paragraph",
|
||||
},
|
||||
]);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "blocks.patch",
|
||||
commandId: "cmd_block_patch_1",
|
||||
functionName: "documents:updateContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
blockId: "blk_1",
|
||||
nextBlock: {
|
||||
id: "blk_1",
|
||||
type: "heading",
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
});
|
||||
|
||||
const result = await executeBlockPatchBridgeCommand({
|
||||
request: new Request("http://127.0.0.1:3000/api/documents/blocks/patch", {
|
||||
method: "POST",
|
||||
}),
|
||||
sourceDocumentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
blockId: "blk_1",
|
||||
nextBlock: {
|
||||
id: "blk_1",
|
||||
type: "heading",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.commandName).toBe("blocks.patch");
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: expect.objectContaining({
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
envelope: expect.objectContaining({
|
||||
name: "blocks.patch",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "blocks.patch",
|
||||
}),
|
||||
result: {
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -164,11 +164,17 @@ export async function executeBlockPatchBridgeCommand(input: {
|
||||
throw new Error("块不存在或无权限");
|
||||
}
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
@@ -217,8 +223,14 @@ export async function executeBlockMoveBridgeCommand(input: {
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({ client, plan });
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
@@ -276,8 +288,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
|
||||
});
|
||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||
try {
|
||||
await executeRustBridgeMutationTransport({ client, plan });
|
||||
await recordBridgeCommandArtifacts({ context, envelope });
|
||||
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { BridgeContext, CommandEnvelope } from "@/lib/documents/bridge";
|
||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
bridgeLogs: {
|
||||
recordCommandLog: "bridgeLogs.recordCommandLog",
|
||||
recordDomainEvent: "bridgeLogs.recordDomainEvent",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
DocumentBridgeError: class DocumentBridgeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DocumentBridgeError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const context: BridgeContext = {
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: "sess_1",
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "vitest",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: "idem_1",
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
const envelope: CommandEnvelope<{ documentId: string }> = {
|
||||
name: "tree.node.archive",
|
||||
commandId: "cmd_1",
|
||||
idempotencyKey: "idem_1",
|
||||
actor: context.actor,
|
||||
source: context.source,
|
||||
target: {
|
||||
workspaceId: "ws_1",
|
||||
pageId: "page_1",
|
||||
},
|
||||
payload: {
|
||||
documentId: "page_1",
|
||||
},
|
||||
reason: null,
|
||||
refs: ["test"],
|
||||
dryRun: false,
|
||||
validateOnly: false,
|
||||
};
|
||||
|
||||
describe("bridge-log", () => {
|
||||
it("记录成功 artifact 时应把 streamDelta 同步写入 domain event payload", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
now: "2026-04-26T00:00:00.000Z",
|
||||
domainEventType: "tree.node.archived",
|
||||
commandPayload: {
|
||||
documentId: "page_1",
|
||||
streamDelta: {
|
||||
op: "remove_document",
|
||||
documentId: "page_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"bridgeLogs.recordDomainEvent",
|
||||
expect.objectContaining({
|
||||
eventType: "tree.node.archived",
|
||||
payload: expect.objectContaining({
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.node.archived",
|
||||
aggregate: {
|
||||
type: "page",
|
||||
id: "page_1",
|
||||
},
|
||||
command_id: "cmd_1",
|
||||
command_name: "tree.node.archive",
|
||||
command: {
|
||||
id: "cmd_1",
|
||||
name: "tree.node.archive",
|
||||
idempotencyKey: "idem_1",
|
||||
},
|
||||
trace: {
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
},
|
||||
error: null,
|
||||
streamDelta: {
|
||||
op: "remove_document",
|
||||
documentId: "page_1",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("记录 artifact 时应优先消费 Rust domainEventPlan", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context,
|
||||
envelope,
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
now: "2026-04-26T00:00:00.000Z",
|
||||
domainEventPlan: {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "page_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
},
|
||||
domainEventType: "legacy.should_not_win",
|
||||
commandPayload: {
|
||||
documentId: "page_1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"bridgeLogs.recordDomainEvent",
|
||||
expect.objectContaining({
|
||||
eventType: "tree.subtree.moved",
|
||||
payload: expect.objectContaining({
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "page_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||
@@ -19,11 +20,74 @@ function normalizeWorkspaceId(context: BridgeContext, target?: BridgeTarget | nu
|
||||
return target?.workspaceId?.trim() || context.workspaceId?.trim() || null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStreamDelta(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
return payload.streamDelta ?? payload.stream_delta ?? null;
|
||||
}
|
||||
|
||||
function normalizeDomainEventType(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function buildTreeDomainEventPayload(input: {
|
||||
context: BridgeContext;
|
||||
commandName: string;
|
||||
commandId: string;
|
||||
idempotencyKey?: string | null;
|
||||
eventType: string;
|
||||
aggregateType: string;
|
||||
aggregateId: string;
|
||||
streamDelta: unknown;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const payload: Record<string, unknown> = {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: input.eventType,
|
||||
aggregate: {
|
||||
type: input.aggregateType,
|
||||
id: input.aggregateId,
|
||||
},
|
||||
trace: {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
},
|
||||
command: {
|
||||
id: input.commandId,
|
||||
name: input.commandName,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
},
|
||||
error: input.error ?? null,
|
||||
// 兼容既有观测与 SSE 解析字段,正式消费者应优先使用上面的结构化字段。
|
||||
request_id: input.context.requestId,
|
||||
trace_id: input.context.traceId,
|
||||
command_id: input.commandId,
|
||||
command_name: input.commandName,
|
||||
idempotency_key: input.idempotencyKey ?? null,
|
||||
};
|
||||
if (input.streamDelta) {
|
||||
payload.streamDelta = input.streamDelta;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<T>;
|
||||
client?: ConvexHttpClient;
|
||||
commandPayload?: unknown;
|
||||
domainEventPlan?: RustTreeDomainEventPlan | null;
|
||||
domainEventType?: string | null;
|
||||
status?: BridgeCommandLogStatus;
|
||||
eventStatus?: BridgeDomainEventStatus;
|
||||
error?: string | null;
|
||||
@@ -44,6 +108,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
|
||||
const aggregateId =
|
||||
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
|
||||
const streamDelta = input.domainEventPlan?.streamDelta ?? readStreamDelta(payload);
|
||||
const eventType =
|
||||
normalizeDomainEventType(input.domainEventPlan?.eventType) ??
|
||||
normalizeDomainEventType(input.domainEventType) ?? `${input.envelope.name}.requested`;
|
||||
|
||||
await client.mutation(api.bridgeLogs.recordCommandLog, {
|
||||
workspaceId,
|
||||
@@ -75,20 +143,23 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandLogId,
|
||||
eventType: `${input.envelope.name}.requested`,
|
||||
eventType,
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
eventVersion: 1,
|
||||
status: eventStatus,
|
||||
actorType: input.context.actor.actorType,
|
||||
payload: {
|
||||
request_id: input.context.requestId,
|
||||
trace_id: input.context.traceId,
|
||||
command_id: input.envelope.commandId,
|
||||
command_name: input.envelope.name,
|
||||
idempotency_key: input.envelope.idempotencyKey,
|
||||
payload: buildTreeDomainEventPayload({
|
||||
context: input.context,
|
||||
commandName: input.envelope.name,
|
||||
commandId: input.envelope.commandId,
|
||||
idempotencyKey: input.envelope.idempotencyKey,
|
||||
eventType,
|
||||
aggregateType,
|
||||
aggregateId,
|
||||
streamDelta,
|
||||
error: input.error ?? null,
|
||||
},
|
||||
}),
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/convex/route", () => ({
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
@@ -478,14 +479,43 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
|
||||
it("executeMetadataBridgeCommand routes options update through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation,
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.options.update",
|
||||
commandId: "cmd_options_1",
|
||||
functionName: "documents:updateOptions",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
options: {
|
||||
showToc: true,
|
||||
layoutDensity: "compact",
|
||||
embedDefaultBlockId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await executeMetadataBridgeCommand({
|
||||
context: mockContext,
|
||||
@@ -505,31 +535,119 @@ describe("documents bridge helpers", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
||||
id: "doc_1",
|
||||
options: {
|
||||
wideLayout: undefined,
|
||||
smallText: undefined,
|
||||
showHeadingNumbers: undefined,
|
||||
showToc: true,
|
||||
showStructure: undefined,
|
||||
protectEditing: undefined,
|
||||
showWordCount: undefined,
|
||||
collapseBacklinks: undefined,
|
||||
pageFont: undefined,
|
||||
layoutDensity: "compact",
|
||||
hideChildPages: undefined,
|
||||
showBlockRefCount: undefined,
|
||||
embedDefaultBlockId: null,
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.options.update",
|
||||
}),
|
||||
});
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.options.update",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "documents.options.update",
|
||||
functionName: "documents:updateOptions",
|
||||
}),
|
||||
result: {
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result.commandName).toBe("documents.options.update");
|
||||
});
|
||||
|
||||
it("executeMetadataBridgeCommand routes stats update through rust runtime", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
mutation: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
}),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.stats.update",
|
||||
commandId: "cmd_stats_1",
|
||||
functionName: "documents:updateStats",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
wordCount: 12,
|
||||
characterCount: 34,
|
||||
blockCount: 5,
|
||||
todoTotal: 6,
|
||||
todoDone: 2,
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await executeMetadataBridgeCommand({
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
name: "documents.stats.update",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
stats: {
|
||||
wordCount: 12,
|
||||
characterCount: 34,
|
||||
blockCount: 5,
|
||||
todoTotal: 6,
|
||||
todoDone: 2,
|
||||
},
|
||||
},
|
||||
context: mockContext,
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.stats.update",
|
||||
}),
|
||||
});
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.stats.update",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "documents.stats.update",
|
||||
functionName: "documents:updateStats",
|
||||
}),
|
||||
result: {
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result.commandName).toBe("documents.stats.update");
|
||||
});
|
||||
|
||||
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -540,7 +658,7 @@ describe("documents bridge helpers", () => {
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.save",
|
||||
@@ -563,7 +681,7 @@ describe("documents bridge helpers", () => {
|
||||
revision: 7,
|
||||
conflict_detection_key: "conflict_1",
|
||||
});
|
||||
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
|
||||
const previousBridgeArtifactCalls = vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length;
|
||||
|
||||
const payload = buildDocumentSavePayload({
|
||||
documentId: "doc_1",
|
||||
@@ -603,16 +721,25 @@ describe("documents bridge helpers", () => {
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
|
||||
expect(vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length).toBe(
|
||||
previousBridgeArtifactCalls + 1,
|
||||
);
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.save",
|
||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||
payload,
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "documents.save",
|
||||
functionName: "documents:updateContent",
|
||||
}),
|
||||
result: {
|
||||
revision: 7,
|
||||
conflict_detection_key: "conflict_1",
|
||||
},
|
||||
});
|
||||
expect(result.requestId).toBe("req_1");
|
||||
expect(result.traceId).toBe("trace_1");
|
||||
@@ -680,7 +807,7 @@ describe("documents bridge helpers", () => {
|
||||
|
||||
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -691,7 +818,7 @@ describe("documents bridge helpers", () => {
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "documents.create",
|
||||
@@ -746,12 +873,20 @@ describe("documents bridge helpers", () => {
|
||||
functionName: "documents:createWithParentReference",
|
||||
}),
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "documents.create",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "documents.create",
|
||||
functionName: "documents:createWithParentReference",
|
||||
}),
|
||||
result: {
|
||||
id: "doc_1",
|
||||
title: "无标题",
|
||||
},
|
||||
});
|
||||
expect(result.result).toEqual({
|
||||
id: "doc_1",
|
||||
@@ -760,13 +895,39 @@ describe("documents bridge helpers", () => {
|
||||
});
|
||||
|
||||
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "media.assets.replace_storage",
|
||||
commandId: "cmd_asset_1",
|
||||
functionName: "mediaAssets:replaceStorageFromUpload",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
storageId: "storage_1",
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
ok: true,
|
||||
fileUrl: "https://example.com/file.docx",
|
||||
});
|
||||
|
||||
await executeMediaAssetWritebackBridgeCommand({
|
||||
client: {
|
||||
mutation,
|
||||
mutation: vi.fn(),
|
||||
} as unknown as ConvexHttpClient,
|
||||
context: mockContext,
|
||||
envelope: buildDocumentCommandEnvelope({
|
||||
@@ -783,18 +944,26 @@ describe("documents bridge helpers", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
||||
userId: "user_1",
|
||||
id: "asset_1",
|
||||
storageId: "storage_1",
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "media.assets.replace_storage",
|
||||
}),
|
||||
});
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "media.assets.replace_storage",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "media.assets.replace_storage",
|
||||
functionName: "mediaAssets:replaceStorageFromUpload",
|
||||
}),
|
||||
result: {
|
||||
ok: true,
|
||||
fileUrl: "https://example.com/file.docx",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertDocumentMoveOrderPlanMatches,
|
||||
buildDocumentMoveOrderPlanFromDocuments,
|
||||
} from "../../../convex/_utils/documentMoveOrder";
|
||||
|
||||
describe("documentMoveOrder", () => {
|
||||
it("按 Rust canonical move order plan 计算跨父移动和越界 clamp", () => {
|
||||
const plan = buildDocumentMoveOrderPlanFromDocuments({
|
||||
documents: [
|
||||
{
|
||||
id: "target",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
created_at: "2026-04-25T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "doc_a",
|
||||
parent_id: "source",
|
||||
sort_order: 0,
|
||||
created_at: "2026-04-25T00:00:01Z",
|
||||
},
|
||||
{
|
||||
id: "doc_b",
|
||||
parent_id: "source",
|
||||
sort_order: 1,
|
||||
created_at: "2026-04-25T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: "doc_c",
|
||||
parent_id: "target",
|
||||
sort_order: 0,
|
||||
created_at: "2026-04-25T00:00:03Z",
|
||||
},
|
||||
],
|
||||
documentId: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: -2,
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
toParentId: "target",
|
||||
requestedSortOrder: -2,
|
||||
normalizedSortOrder: 0,
|
||||
patches: [
|
||||
{
|
||||
documentId: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
moved: true,
|
||||
},
|
||||
{
|
||||
documentId: "doc_c",
|
||||
parentId: "target",
|
||||
sortOrder: 1,
|
||||
moved: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => {
|
||||
const actual = buildDocumentMoveOrderPlanFromDocuments({
|
||||
documents: [
|
||||
{
|
||||
id: "doc_a",
|
||||
parent_id: "source",
|
||||
sort_order: 0,
|
||||
created_at: "2026-04-25T00:00:01Z",
|
||||
},
|
||||
{
|
||||
id: "doc_b",
|
||||
parent_id: "source",
|
||||
sort_order: 1,
|
||||
created_at: "2026-04-25T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: "doc_c",
|
||||
parent_id: "target",
|
||||
sort_order: 0,
|
||||
created_at: "2026-04-25T00:00:03Z",
|
||||
},
|
||||
],
|
||||
documentId: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertDocumentMoveOrderPlanMatches(
|
||||
{
|
||||
...actual,
|
||||
patches: actual.patches.map((patch) =>
|
||||
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
|
||||
),
|
||||
},
|
||||
actual,
|
||||
),
|
||||
).toThrow("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
|
||||
export type MediaAssetReplaceStoragePayload = {
|
||||
assetId: string;
|
||||
@@ -32,21 +32,21 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<MediaAssetReplaceStoragePayload>;
|
||||
}): Promise<MediaAssetWritebackExecutionResult> {
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: (payload) => ({
|
||||
userId: payload.userId,
|
||||
id: payload.assetId,
|
||||
storageId: payload.storageId as Id<"_storage">,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client: input.client,
|
||||
mutation: api.mediaAssets.replaceStorageFromUpload,
|
||||
request: mutationRequest,
|
||||
plan,
|
||||
});
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client: input.client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
@@ -58,17 +58,6 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await recordBridgeCommandArtifacts({
|
||||
client: input.client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
} catch (error) {
|
||||
// 说明:OnlyOffice callback 写回的主事实是附件存储替换;日志落账失败不应反向导致保存失败。
|
||||
console.warn("[onlyoffice/callback] bridge log write skipped:", error);
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type CommandEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -47,111 +44,27 @@ export type MetadataCommandExecutionResult = {
|
||||
commandName: string;
|
||||
};
|
||||
|
||||
type MetadataMutationArgs = Record<string, unknown>;
|
||||
|
||||
type MetadataWriteAdapter<TPayload> = {
|
||||
convexMutation: unknown;
|
||||
mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
|
||||
};
|
||||
|
||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||
return {
|
||||
id: payload.documentId,
|
||||
options: {
|
||||
wideLayout: payload.options.wideLayout,
|
||||
smallText: payload.options.smallText,
|
||||
showHeadingNumbers: payload.options.showHeadingNumbers,
|
||||
showToc: payload.options.showToc,
|
||||
showStructure: payload.options.showStructure,
|
||||
protectEditing: payload.options.protectEditing,
|
||||
showWordCount: payload.options.showWordCount,
|
||||
collapseBacklinks: payload.options.collapseBacklinks,
|
||||
pageFont: payload.options.pageFont,
|
||||
layoutDensity: payload.options.layoutDensity,
|
||||
hideChildPages: payload.options.hideChildPages,
|
||||
showBlockRefCount: payload.options.showBlockRefCount,
|
||||
embedDefaultBlockId:
|
||||
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
|
||||
"documents.title.update": {
|
||||
convexMutation: api.documents.updateTitle,
|
||||
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
}),
|
||||
},
|
||||
"page.head.updateTitle": {
|
||||
convexMutation: api.documents.updateTitle,
|
||||
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
}),
|
||||
},
|
||||
"documents.stats.update": {
|
||||
convexMutation: api.documents.updateStats,
|
||||
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
|
||||
id: payload.documentId,
|
||||
wordCount: payload.stats.wordCount,
|
||||
characterCount: payload.stats.characterCount,
|
||||
blockCount: payload.stats.blockCount,
|
||||
todoTotal: payload.stats.todoTotal,
|
||||
todoDone: payload.stats.todoDone,
|
||||
}),
|
||||
},
|
||||
"documents.options.update": {
|
||||
convexMutation: api.documents.updateOptions,
|
||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
||||
},
|
||||
"page.layout.updateOptions": {
|
||||
convexMutation: api.documents.updateOptions,
|
||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
||||
},
|
||||
};
|
||||
|
||||
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
|
||||
const adapter = metadataWriteAdapters[commandName];
|
||||
if (!adapter) {
|
||||
throw new Error(`未注册页面元信息命令适配器: ${commandName}`);
|
||||
}
|
||||
return adapter as MetadataWriteAdapter<TPayload>;
|
||||
}
|
||||
|
||||
export async function executeMetadataBridgeCommand<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}): Promise<MetadataCommandExecutionResult> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
try {
|
||||
if (
|
||||
input.envelope.name === "documents.title.update" ||
|
||||
input.envelope.name === "page.head.updateTitle"
|
||||
) {
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
} else {
|
||||
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: adapter.mapConvexArgs,
|
||||
});
|
||||
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
client,
|
||||
mutation: adapter.convexMutation,
|
||||
request: mutationRequest,
|
||||
});
|
||||
}
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
context: input.context,
|
||||
@@ -161,10 +74,6 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import {
|
||||
@@ -147,10 +147,12 @@ export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input
|
||||
plan,
|
||||
});
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
client,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
|
||||
@@ -43,6 +43,7 @@ vi.mock("@/lib/documents/bridge", () => ({
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
type CommandEnvelope,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
@@ -257,9 +257,20 @@ async function handleLifecycleError(error: unknown) {
|
||||
|
||||
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
|
||||
let failureContext: BridgeContext | null = null;
|
||||
let failureEnvelope: CommandEnvelope<{
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
accessScope: "private" | "shared" | "public";
|
||||
content: unknown[];
|
||||
}> | null = null;
|
||||
try {
|
||||
const payload = (await request.json()) as CreatePayload;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
failureClient = client;
|
||||
|
||||
const parentId = trimOrNull(payload.parentId);
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
@@ -304,6 +315,8 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
failureContext = context;
|
||||
failureEnvelope = envelope;
|
||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||
request,
|
||||
workspaceId,
|
||||
@@ -331,38 +344,22 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
|
||||
}
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result: created,
|
||||
});
|
||||
|
||||
return NextResponse.json(created);
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as MovePayload;
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (documentId) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: trimOrNull(payload.parentId),
|
||||
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
||||
pageId: documentId,
|
||||
},
|
||||
});
|
||||
if (failureClient && failureContext && failureEnvelope) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
client: failureClient,
|
||||
context: failureContext,
|
||||
envelope: failureEnvelope,
|
||||
error,
|
||||
});
|
||||
}
|
||||
@@ -427,14 +424,16 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
@@ -529,14 +528,16 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -548,7 +549,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.restore",
|
||||
name: "documents.delete",
|
||||
payload: { documentId },
|
||||
context,
|
||||
target: {
|
||||
@@ -600,14 +601,16 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
|
||||
authUserId: auth.userId,
|
||||
envelope,
|
||||
});
|
||||
await executeRustBridgeMutationTransport({
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -620,12 +623,8 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
|
||||
const workspaceId = sourceDoc?.workspace_id ?? null;
|
||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.duplicate",
|
||||
payload: {
|
||||
sourceDocumentId: documentId,
|
||||
newDocumentId: "duplicate_failed",
|
||||
title: sourceDoc?.title ?? null,
|
||||
},
|
||||
name: "documents.restore",
|
||||
payload: { documentId },
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
@@ -689,8 +688,10 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
||||
title: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number | null;
|
||||
is_starred: boolean;
|
||||
workspace_id: string;
|
||||
access_scope: "private" | "shared" | "public";
|
||||
is_template: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>({
|
||||
@@ -701,10 +702,12 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await copyMindmapIfExists(documentId, duplicated.id);
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: runtimeContext,
|
||||
envelope,
|
||||
client,
|
||||
plan,
|
||||
result: duplicated,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -715,26 +718,32 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
|
||||
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const context = await buildBridgeContext(request, null, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.copy_tree",
|
||||
payload: {
|
||||
items: (payload.items ?? []).map((item) => ({
|
||||
documentId: trimOrNull(item.documentId) ?? "unknown",
|
||||
recursive: Boolean(item.recursive),
|
||||
})),
|
||||
targetParentId: trimOrNull(payload.targetParentId),
|
||||
},
|
||||
context,
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
const documentId = trimOrNull(payload.documentId);
|
||||
if (documentId) {
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
const workspaceId = sourceDoc?.workspace_id ?? null;
|
||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.duplicate",
|
||||
payload: {
|
||||
sourceDocumentId: documentId,
|
||||
newDocumentId: "duplicate_failed",
|
||||
title: sourceDoc?.title ?? null,
|
||||
},
|
||||
context,
|
||||
target: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
client,
|
||||
context,
|
||||
envelope,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 忽略日志写失败,保留原始错误返回
|
||||
}
|
||||
@@ -809,10 +818,12 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
|
||||
await copyMindmapIfExists(item.oldId, item.newId);
|
||||
}
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: runtimeContext,
|
||||
envelope: outerEnvelope,
|
||||
client,
|
||||
plan,
|
||||
result,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("@/lib/convex/route", () => ({
|
||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||
recordBridgeCommandArtifacts: vi.fn(),
|
||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
@@ -69,7 +70,7 @@ const mockContext: BridgeContext = {
|
||||
describe("page-write-command-adapter", () => {
|
||||
it("标题命令应走 rust bridge transport", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
@@ -120,23 +121,18 @@ describe("page-write-command-adapter", () => {
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
});
|
||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "page.head.updateTitle",
|
||||
}),
|
||||
commandPayload: {
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
title: "新标题",
|
||||
streamDelta: {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: "doc_1",
|
||||
title: "新标题",
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
plan: expect.objectContaining({
|
||||
commandName: "page.head.updateTitle",
|
||||
}),
|
||||
result: {
|
||||
ok: true,
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result.commandName).toBe("page.head.updateTitle");
|
||||
@@ -144,12 +140,41 @@ describe("page-write-command-adapter", () => {
|
||||
expect(result.conflictDetectionKey).toBeNull();
|
||||
});
|
||||
|
||||
it("页面设置命令应走 bridge mutation request", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
it("页面设置命令应走 rust bridge transport", async () => {
|
||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||
const {
|
||||
resolveRustBridgeCommandPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
} = await import("@/lib/documents/rust-runtime");
|
||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||
auth: { userId: "user_1" },
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
|
||||
});
|
||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||
kind: "command",
|
||||
commandName: "page.layout.updateOptions",
|
||||
commandId: "cmd_options_1",
|
||||
functionName: "documents:updateOptions",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{\"kind\":\"command\"}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
options: {
|
||||
showToc: true,
|
||||
layoutDensity: "compact",
|
||||
embedDefaultBlockId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = await executePageWriteBridgeCommand({
|
||||
@@ -170,7 +195,27 @@ describe("page-write-command-adapter", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "page.layout.updateOptions",
|
||||
}),
|
||||
});
|
||||
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||
client: expect.any(Object),
|
||||
context: mockContext,
|
||||
envelope: expect.objectContaining({
|
||||
name: "page.layout.updateOptions",
|
||||
}),
|
||||
plan: expect.objectContaining({
|
||||
commandName: "page.layout.updateOptions",
|
||||
functionName: "documents:updateOptions",
|
||||
}),
|
||||
result: {
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(result.commandName).toBe("page.layout.updateOptions");
|
||||
expect(result.revision).toBeNull();
|
||||
expect(result.conflictDetectionKey).toBeNull();
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
buildDocumentBridgeMutationRequest,
|
||||
executeDocumentBridgeMutationRequest,
|
||||
type BridgeContext,
|
||||
type CommandEnvelope,
|
||||
DocumentBridgeError,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
|
||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
@@ -23,14 +20,6 @@ type PageWritePayload =
|
||||
| DocumentOptionsUpdatePayload
|
||||
| DocumentSavePayload;
|
||||
|
||||
type MetadataMutationArgs = Record<string, unknown>;
|
||||
|
||||
type PageWriteAdapter<TPayload> = {
|
||||
kind: "rust_transport" | "convex_mutation";
|
||||
convexMutation?: unknown;
|
||||
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
|
||||
};
|
||||
|
||||
export type PageWriteCommandExecutionResult = {
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
@@ -40,70 +29,6 @@ export type PageWriteCommandExecutionResult = {
|
||||
conflictDetectionKey: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
||||
if (!streamDelta) {
|
||||
return commandPayload;
|
||||
}
|
||||
if (isRecord(commandPayload)) {
|
||||
return {
|
||||
...commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
payload: commandPayload,
|
||||
streamDelta,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
||||
return {
|
||||
id: payload.documentId,
|
||||
options: {
|
||||
wideLayout: payload.options.wideLayout,
|
||||
smallText: payload.options.smallText,
|
||||
showHeadingNumbers: payload.options.showHeadingNumbers,
|
||||
showToc: payload.options.showToc,
|
||||
showStructure: payload.options.showStructure,
|
||||
protectEditing: payload.options.protectEditing,
|
||||
showWordCount: payload.options.showWordCount,
|
||||
collapseBacklinks: payload.options.collapseBacklinks,
|
||||
pageFont: payload.options.pageFont,
|
||||
layoutDensity: payload.options.layoutDensity,
|
||||
hideChildPages: payload.options.hideChildPages,
|
||||
showBlockRefCount: payload.options.showBlockRefCount,
|
||||
embedDefaultBlockId:
|
||||
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
|
||||
"page.head.updateTitle": {
|
||||
kind: "rust_transport",
|
||||
},
|
||||
"page.layout.updateOptions": {
|
||||
kind: "convex_mutation",
|
||||
convexMutation: api.documents.updateOptions,
|
||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
||||
},
|
||||
"page.body.save": {
|
||||
kind: "rust_transport",
|
||||
},
|
||||
};
|
||||
|
||||
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
|
||||
const adapter = pageWriteAdapters[commandName];
|
||||
if (!adapter) {
|
||||
throw new Error(`未注册页面写命令适配器: ${commandName}`);
|
||||
}
|
||||
return adapter as PageWriteAdapter<TPayload>;
|
||||
}
|
||||
|
||||
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
|
||||
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
|
||||
return {
|
||||
@@ -118,86 +43,29 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUpdatedAt(result: unknown): string | null {
|
||||
const record = isRecord(result) ? result : null;
|
||||
const updatedAt = record?.updated_at;
|
||||
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
|
||||
}
|
||||
|
||||
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
transportResult?: unknown;
|
||||
}) {
|
||||
if (input.envelope.name !== "page.head.updateTitle") {
|
||||
return input.envelope.payload;
|
||||
}
|
||||
|
||||
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
|
||||
const updatedAt = normalizeUpdatedAt(input.transportResult);
|
||||
|
||||
return attachStreamDelta(payload, {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: payload.documentId,
|
||||
title: payload.title,
|
||||
...(updatedAt ? { updated_at: updatedAt } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<TPayload>;
|
||||
}): Promise<PageWriteCommandExecutionResult> {
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
|
||||
|
||||
try {
|
||||
if (adapter.kind === "rust_transport") {
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
});
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const persistedMeta = normalizePersistedMeta(transportResult);
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
commandPayload: buildPageWriteCommandPayload({
|
||||
envelope: input.envelope,
|
||||
transportResult,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: input.context.requestId,
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
revision: persistedMeta.revision,
|
||||
conflictDetectionKey: persistedMeta.conflictDetectionKey,
|
||||
};
|
||||
}
|
||||
|
||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
||||
const plan = await resolveRustBridgeCommandPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
mapConvexArgs: adapter.mapConvexArgs!,
|
||||
});
|
||||
|
||||
await executeDocumentBridgeMutationRequest({
|
||||
const transportResult = await executeRustBridgeMutationTransport({
|
||||
client,
|
||||
mutation: adapter.convexMutation!,
|
||||
request: mutationRequest,
|
||||
plan,
|
||||
});
|
||||
const persistedMeta = normalizePersistedMeta(transportResult);
|
||||
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
plan,
|
||||
result: transportResult,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -205,8 +73,8 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
|
||||
traceId: input.context.traceId,
|
||||
commandId: input.envelope.commandId,
|
||||
commandName: input.envelope.name,
|
||||
revision: null,
|
||||
conflictDetectionKey: null,
|
||||
revision: persistedMeta.revision,
|
||||
conflictDetectionKey: persistedMeta.conflictDetectionKey,
|
||||
};
|
||||
} catch (error) {
|
||||
await recordBridgeCommandFailureArtifacts({
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import {
|
||||
buildRustBridgeCommandArtifactPlan,
|
||||
executeRustBridgeMutationTransport,
|
||||
materializeRustTreeStreamDelta,
|
||||
readRustTreeDomainEventPlan,
|
||||
readRustTreeDomainEventType,
|
||||
type RustBridgeCommandPlan,
|
||||
} from "./rust-runtime";
|
||||
|
||||
vi.mock("@/lib/convex/api", () => ({
|
||||
api: {
|
||||
documents: {
|
||||
move: "documents.move",
|
||||
copyTree: "documents.copyTree",
|
||||
updateStats: "documents.updateStats",
|
||||
},
|
||||
mediaAssets: {
|
||||
batchCopy: "mediaAssets.batchCopy",
|
||||
batchMove: "mediaAssets.batchMove",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
DocumentBridgeError: class DocumentBridgeError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string, status: number, code: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = "DocumentBridgeError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
let runtimeSelection: Record<string, unknown> = {};
|
||||
|
||||
@@ -41,3 +80,609 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeRustBridgeMutationTransport", () => {
|
||||
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
|
||||
const normalizedMove = {
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
toParentId: "target",
|
||||
requestedSortOrder: 0,
|
||||
normalizedSortOrder: 0,
|
||||
patches: [
|
||||
{
|
||||
documentId: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
moved: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.subtree.move",
|
||||
commandId: "cmd_move",
|
||||
functionName: "documents:move",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
id: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
},
|
||||
};
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mutation.mock.calls[0]?.[1]).toMatchObject({
|
||||
id: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
});
|
||||
});
|
||||
|
||||
it("documents.copyTree 应注册为 Rust tree.subtree.copy 的 transport", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ items: [] });
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.subtree.copy",
|
||||
commandId: "cmd_copy",
|
||||
functionName: "documents:copyTree",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
targetParentId: "parent_1",
|
||||
items: [
|
||||
{
|
||||
documentId: "doc_1",
|
||||
recursive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith("documents.copyTree", {
|
||||
targetParentId: "parent_1",
|
||||
items: [
|
||||
{
|
||||
documentId: "doc_1",
|
||||
recursive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("documents.updateStats 应注册为 Rust metadata transport", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
updated_at: "2026-04-26T00:00:00.000Z",
|
||||
});
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "documents.stats.update",
|
||||
commandId: "cmd_stats",
|
||||
functionName: "documents:updateStats",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
id: "doc_1",
|
||||
wordCount: 12,
|
||||
characterCount: 34,
|
||||
blockCount: 5,
|
||||
todoTotal: 6,
|
||||
todoDone: 2,
|
||||
},
|
||||
};
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith("documents.updateStats", {
|
||||
id: "doc_1",
|
||||
wordCount: 12,
|
||||
characterCount: 34,
|
||||
blockCount: 5,
|
||||
todoTotal: 6,
|
||||
todoDone: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("tree.resource.copy/move 应把 Rust resourceTransferPlan 透传给媒体 transport", async () => {
|
||||
const mutation = vi.fn().mockResolvedValue({ items: [] });
|
||||
const basePlan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.resource.copy",
|
||||
commandId: "cmd_asset_copy",
|
||||
functionName: "mediaAssets:batchCopy",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
resourceTransferPlan: {
|
||||
action: "copy",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan: basePlan,
|
||||
});
|
||||
|
||||
await executeRustBridgeMutationTransport({
|
||||
client: { mutation } as unknown as ConvexHttpClient,
|
||||
plan: {
|
||||
...basePlan,
|
||||
commandName: "tree.resource.move",
|
||||
commandId: "cmd_asset_move",
|
||||
functionName: "mediaAssets:batchMove",
|
||||
argsJson: {
|
||||
...basePlan.argsJson,
|
||||
resourceTransferPlan: {
|
||||
action: "move",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenNthCalledWith(1, "mediaAssets.batchCopy", {
|
||||
userId: "user_1",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
resourceTransferPlan: {
|
||||
action: "copy",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
});
|
||||
expect(mutation).toHaveBeenNthCalledWith(2, "mediaAssets.batchMove", {
|
||||
userId: "user_1",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
resourceTransferPlan: {
|
||||
action: "move",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializeRustTreeStreamDelta", () => {
|
||||
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.subtree.move",
|
||||
commandId: "cmd_move",
|
||||
functionName: "documents:move",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_requested",
|
||||
sortOrder: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
parent_id: "parent_actual",
|
||||
sort_order: 2,
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_actual",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("应按 Rust copy_result hint 从结果 items 中生成 upsert_documents delta", () => {
|
||||
const document = {
|
||||
id: "copy_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "复制页面",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-26T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
};
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.subtree.copy",
|
||||
commandId: "cmd_copy",
|
||||
functionName: "documents:copyTree",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "copy_result",
|
||||
args: {
|
||||
itemsField: "items",
|
||||
documentField: "document",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
oldId: "doc_1",
|
||||
newId: "copy_1",
|
||||
document,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "upsert_documents",
|
||||
upsertDocuments: [document],
|
||||
});
|
||||
});
|
||||
|
||||
it("应按 Rust result_document hint 从根结果生成 upsert_document delta", () => {
|
||||
const document = {
|
||||
id: "copy_2",
|
||||
workspace_id: "ws_1",
|
||||
title: "复制页面 2",
|
||||
parent_id: "parent_1",
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-26T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
};
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "documents.duplicate",
|
||||
commandId: "cmd_duplicate",
|
||||
functionName: "documents:duplicateWithMindmaps",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "result_document",
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: document,
|
||||
}),
|
||||
).toEqual({
|
||||
op: "upsert_document",
|
||||
document,
|
||||
});
|
||||
});
|
||||
|
||||
it("应按 Rust asset_result hint 从结果 items 中生成 upsert_assets delta", () => {
|
||||
const asset = {
|
||||
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",
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
created_at: "2026-04-26T00:00:00Z",
|
||||
updated_at: "2026-04-26T00:00:00Z",
|
||||
};
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.resource.move",
|
||||
commandId: "cmd_asset_move",
|
||||
functionName: "mediaAssets:batchMove",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "asset_result",
|
||||
args: {
|
||||
itemsField: "items",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
materializeRustTreeStreamDelta({
|
||||
plan,
|
||||
result: {
|
||||
items: [asset],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
op: "upsert_assets",
|
||||
upsertAssets: [asset],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRustTreeDomainEventType", () => {
|
||||
it("应从 Rust domainEventHint 读取正式树域 event type", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.node.archive",
|
||||
commandId: "cmd_archive",
|
||||
functionName: "documents:softDelete",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: null,
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "tree.node.archived",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(readRustTreeDomainEventType(plan)).toBe("tree.node.archived");
|
||||
});
|
||||
|
||||
it("应优先读取 Rust domainEventPlan 作为正式树域事件计划", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "tree.subtree.move",
|
||||
commandId: "cmd_move",
|
||||
functionName: "documents:move",
|
||||
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: "tree.subtree.moved",
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
domainEventHint: {
|
||||
family: "tree",
|
||||
eventType: "legacy.should_not_win",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(readRustTreeDomainEventType(plan)).toBe("tree.subtree.moved");
|
||||
expect(readRustTreeDomainEventPlan(plan)).toEqual({
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
it("应通过 Rust runtime commandArtifact 输入生成 artifact plan", async () => {
|
||||
const artifactPlan = await buildRustBridgeCommandArtifactPlan({
|
||||
context: {
|
||||
deploymentId: null,
|
||||
projectId: null,
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_artifact_1",
|
||||
traceId: "trace_artifact_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "vitest",
|
||||
},
|
||||
tenantId: null,
|
||||
authToken: null,
|
||||
idempotencyKey: "idem_1",
|
||||
validateOnly: false,
|
||||
dryRun: false,
|
||||
},
|
||||
envelope: {
|
||||
name: "tree.subtree.move",
|
||||
commandId: "cmd_artifact_1",
|
||||
idempotencyKey: "idem_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
source: {
|
||||
channel: "next-route",
|
||||
client: "vitest",
|
||||
},
|
||||
target: {
|
||||
workspaceId: "ws_1",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
preflightData: null,
|
||||
reason: "test",
|
||||
refs: ["test"],
|
||||
dryRun: false,
|
||||
validateOnly: false,
|
||||
},
|
||||
plan: {
|
||||
kind: "command",
|
||||
commandName: "tree.subtree.move",
|
||||
commandId: "cmd_artifact_1",
|
||||
functionName: "documents:move",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_artifact_1",
|
||||
traceId: "trace_artifact_1",
|
||||
actorId: "user_1",
|
||||
idempotencyKey: "idem_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: {
|
||||
domainEventPlan: {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "tree.subtree.moved",
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "move_document",
|
||||
args: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
parent_id: "parent_1",
|
||||
sort_order: 2,
|
||||
updated_at: "2026-04-26T10:00:00Z",
|
||||
},
|
||||
now: "2026-04-26T10:00:01Z",
|
||||
});
|
||||
|
||||
expect(artifactPlan?.commandLog).toMatchObject({
|
||||
id: "clog_cmd_artifact_1",
|
||||
workspaceId: "ws_1",
|
||||
commandName: "tree.subtree.move",
|
||||
payload: {
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 0,
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(artifactPlan?.domainEvent).toMatchObject({
|
||||
id: "evt_cmd_artifact_1",
|
||||
eventType: "tree.subtree.moved",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
streamDelta: {
|
||||
op: "move_document",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-26T10:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import {
|
||||
DocumentBridgeError,
|
||||
@@ -37,6 +38,10 @@ type RustRuntimeResponse =
|
||||
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
|
||||
}
|
||||
| RustRuntimeExecutedQuery
|
||||
| {
|
||||
ok: true;
|
||||
artifacts: RustBridgeCommandArtifactPlan | null;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: RustRuntimeErrorPayload;
|
||||
@@ -68,6 +73,88 @@ export type RustBridgeCommandPlan = {
|
||||
argsJson: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RustTreeDomainEventPlan = {
|
||||
family: "tree";
|
||||
schema: "mnote.tree.domain_event";
|
||||
schemaVersion: 1;
|
||||
eventType: string;
|
||||
streamDeltaHint?: Record<string, unknown>;
|
||||
streamDelta?: RustTreeStreamDelta;
|
||||
};
|
||||
|
||||
export type RustTreeStreamDelta =
|
||||
| {
|
||||
op: "noop";
|
||||
}
|
||||
| {
|
||||
op: "upsert_document";
|
||||
document: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
op: "upsert_documents";
|
||||
upsertDocuments: Record<string, unknown>[];
|
||||
}
|
||||
| {
|
||||
op: "remove_document";
|
||||
documentId: string;
|
||||
}
|
||||
| {
|
||||
op: "move_document";
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
| {
|
||||
op: "upsert_assets";
|
||||
upsertAssets: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type RustBridgeCommandLogArtifactPlan = {
|
||||
workspaceId: string;
|
||||
id: string;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
commandId: string;
|
||||
commandName: string;
|
||||
actorId: string;
|
||||
actorType: string;
|
||||
sourceChannel: string;
|
||||
sourceClient: string;
|
||||
status: string;
|
||||
targetPageId: string | null;
|
||||
targetBlockId: string | null;
|
||||
payload: unknown;
|
||||
payloadSummary: string;
|
||||
refs: string[];
|
||||
idempotencyKey: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
finishedAt: string | null;
|
||||
};
|
||||
|
||||
export type RustBridgeDomainEventArtifactPlan = {
|
||||
workspaceId: string;
|
||||
id: string;
|
||||
requestId: string;
|
||||
traceId: string;
|
||||
commandId: string;
|
||||
commandLogId: string;
|
||||
eventType: string;
|
||||
aggregateType: string;
|
||||
aggregateId: string;
|
||||
eventVersion: number;
|
||||
status: string;
|
||||
actorType: string;
|
||||
payload: unknown;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RustBridgeCommandArtifactPlan = {
|
||||
commandLog: RustBridgeCommandLogArtifactPlan;
|
||||
domainEvent: RustBridgeDomainEventArtifactPlan | null;
|
||||
};
|
||||
|
||||
export type RustBridgeToolPlanStep = {
|
||||
kind: string;
|
||||
name: string;
|
||||
@@ -433,6 +520,371 @@ function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string)
|
||||
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
function readStringArrayArg(argsJson: Record<string, unknown>, field: string): string[] {
|
||||
const value = argsJson[field];
|
||||
if (!Array.isArray(value)) {
|
||||
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readRecordField(source: unknown, field: string) {
|
||||
if (!isRecord(source)) {
|
||||
return null;
|
||||
}
|
||||
const value = source[field];
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string) {
|
||||
const value = argsJson[field];
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
|
||||
const value = source[field];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function readOptionalNonEmptyStringField(source: Record<string, unknown>, field: string) {
|
||||
const value = source[field];
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function readNullableStringField(source: Record<string, unknown>, field: string) {
|
||||
const value = source[field];
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readTrimmedStringField(source: unknown, field: string) {
|
||||
if (!isRecord(source)) {
|
||||
return null;
|
||||
}
|
||||
const value = source[field];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function readOptionalParentIdFromResult(result: unknown, fallback: unknown): string | null {
|
||||
if (isRecord(result) && "parent_id" in result) {
|
||||
return readTrimmedStringField(result, "parent_id");
|
||||
}
|
||||
if (typeof fallback === "string") {
|
||||
const trimmed = fallback.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readFiniteNumberField(source: unknown, field: string) {
|
||||
if (!isRecord(source)) {
|
||||
return null;
|
||||
}
|
||||
const value = source[field];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isTreeDeltaDocument(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.id === "string" &&
|
||||
typeof value.workspace_id === "string" &&
|
||||
typeof value.access_scope === "string" &&
|
||||
typeof value.is_template === "boolean" &&
|
||||
typeof value.created_at === "string" &&
|
||||
"title" in value &&
|
||||
"parent_id" in value &&
|
||||
"sort_order" in value &&
|
||||
"is_starred" in value &&
|
||||
"updated_at" in value
|
||||
);
|
||||
}
|
||||
|
||||
function isTreeDeltaAsset(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.id === "string" &&
|
||||
typeof value.workspace_id === "string" &&
|
||||
typeof value.document_id === "string" &&
|
||||
typeof value.asset_type === "string" &&
|
||||
typeof value.created_at === "string" &&
|
||||
typeof value.updated_at === "string" &&
|
||||
"file_name" in value &&
|
||||
"file_url" in value &&
|
||||
"thumbnail_url" in value &&
|
||||
"file_size" in value &&
|
||||
"mime_type" in value
|
||||
);
|
||||
}
|
||||
|
||||
function readStreamDeltaHint(plan: RustBridgeCommandPlan) {
|
||||
const hint = plan.argsJson.streamDeltaHint;
|
||||
if (!isRecord(hint) || hint.family !== "tree" || typeof hint.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: hint.kind,
|
||||
args: isRecord(hint.args) ? hint.args : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string | null {
|
||||
const eventPlan = readRustTreeDomainEventPlan(plan);
|
||||
if (eventPlan) {
|
||||
return eventPlan.eventType;
|
||||
}
|
||||
const hint = plan.argsJson.domainEventHint;
|
||||
if (!isRecord(hint) || hint.family !== "tree") {
|
||||
return null;
|
||||
}
|
||||
return readTrimmedStringField(hint, "eventType");
|
||||
}
|
||||
|
||||
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
|
||||
const eventPlan = plan.argsJson.domainEventPlan;
|
||||
if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
|
||||
return null;
|
||||
}
|
||||
if (eventPlan.schema !== "mnote.tree.domain_event" || eventPlan.schemaVersion !== 1) {
|
||||
return null;
|
||||
}
|
||||
const eventType = readTrimmedStringField(eventPlan, "eventType");
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
|
||||
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
|
||||
return {
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType,
|
||||
...(streamDeltaHint ? { streamDeltaHint } : {}),
|
||||
...(streamDelta ? { streamDelta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeRustTreeDomainEventPlan(input: {
|
||||
plan: RustBridgeCommandPlan;
|
||||
result: unknown;
|
||||
streamDelta?: RustTreeStreamDelta | null;
|
||||
}): RustTreeDomainEventPlan | null {
|
||||
const eventPlan = readRustTreeDomainEventPlan(input.plan);
|
||||
if (!eventPlan) {
|
||||
return null;
|
||||
}
|
||||
const streamDelta =
|
||||
input.streamDelta ??
|
||||
materializeRustTreeStreamDelta({
|
||||
plan: input.plan,
|
||||
result: input.result,
|
||||
});
|
||||
return {
|
||||
...eventPlan,
|
||||
...(streamDelta ? { streamDelta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeRustTreeStreamDelta(input: {
|
||||
plan: RustBridgeCommandPlan;
|
||||
result: unknown;
|
||||
}): RustTreeStreamDelta | null {
|
||||
const hint = readStreamDeltaHint(input.plan);
|
||||
if (!hint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hint.kind === "noop") {
|
||||
return { op: "noop" };
|
||||
}
|
||||
|
||||
if (hint.kind === "remove_document") {
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
return documentId ? { op: "remove_document", documentId } : null;
|
||||
}
|
||||
|
||||
if (hint.kind === "upsert_document_patch") {
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
const patch = readRecordField(hint.args, "patch");
|
||||
if (!documentId || !patch) {
|
||||
return null;
|
||||
}
|
||||
const updatedAt = readTrimmedStringField(input.result, "updated_at");
|
||||
return {
|
||||
op: "upsert_document",
|
||||
document: {
|
||||
id: documentId,
|
||||
...patch,
|
||||
...(updatedAt && !("updated_at" in patch) ? { updated_at: updatedAt } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (hint.kind === "document_result") {
|
||||
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
|
||||
const document = readRecordField(input.result, documentField);
|
||||
if (!isTreeDeltaDocument(document)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
op: "upsert_document",
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
if (hint.kind === "result_document") {
|
||||
if (!isTreeDeltaDocument(input.result)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
op: "upsert_document",
|
||||
document: input.result,
|
||||
};
|
||||
}
|
||||
|
||||
if (hint.kind === "copy_result") {
|
||||
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
|
||||
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
|
||||
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
|
||||
const upsertDocuments = items
|
||||
.map((item) => readRecordField(item, documentField))
|
||||
.filter(isTreeDeltaDocument);
|
||||
return upsertDocuments.length > 0
|
||||
? {
|
||||
op: "upsert_documents",
|
||||
upsertDocuments,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (hint.kind === "asset_result") {
|
||||
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
|
||||
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
|
||||
const upsertAssets = items.filter(isTreeDeltaAsset);
|
||||
return upsertAssets.length > 0
|
||||
? {
|
||||
op: "upsert_assets",
|
||||
upsertAssets,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (hint.kind === "move_document") {
|
||||
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||
const fallbackSortOrder = readFiniteNumberField(hint.args, "sortOrder");
|
||||
const sortOrder = readFiniteNumberField(input.result, "sort_order") ?? fallbackSortOrder;
|
||||
if (!documentId || typeof sortOrder !== "number") {
|
||||
return null;
|
||||
}
|
||||
const updatedAt = readTrimmedStringField(input.result, "updated_at");
|
||||
return {
|
||||
op: "move_document",
|
||||
documentId,
|
||||
parentId: readOptionalParentIdFromResult(input.result, hint.args.parentId),
|
||||
sortOrder,
|
||||
...(updatedAt ? { updatedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function nowIsoForRustArtifact() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export async function buildRustBridgeCommandArtifactPlan(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<unknown>;
|
||||
plan: RustBridgeCommandPlan;
|
||||
result: unknown;
|
||||
now?: string;
|
||||
}): Promise<RustBridgeCommandArtifactPlan | null> {
|
||||
const response = await runRustRuntime({
|
||||
kind: "commandArtifact",
|
||||
context: input.context,
|
||||
command: {
|
||||
...input.envelope,
|
||||
preflightData: input.envelope.preflightData ?? null,
|
||||
},
|
||||
plan: input.plan,
|
||||
result: input.result,
|
||||
now: input.now ?? nowIsoForRustArtifact(),
|
||||
});
|
||||
|
||||
if (!("artifacts" in response)) {
|
||||
throw new DocumentBridgeError("Rust runtime 未返回 command artifact plan", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return response.artifacts;
|
||||
}
|
||||
|
||||
export async function persistRustBridgeCommandArtifacts(input: {
|
||||
client: ConvexHttpClient;
|
||||
artifacts: RustBridgeCommandArtifactPlan | null;
|
||||
}): Promise<void> {
|
||||
const artifacts = input.artifacts;
|
||||
if (!artifacts) {
|
||||
return;
|
||||
}
|
||||
const bridgeLogsApi = api as typeof api & {
|
||||
bridgeLogs: {
|
||||
recordCommandLog: unknown;
|
||||
recordDomainEvent: unknown;
|
||||
};
|
||||
};
|
||||
const mutation = input.client.mutation.bind(input.client) as (
|
||||
mutationReference: unknown,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>);
|
||||
if (artifacts.domainEvent) {
|
||||
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordRustBridgeCommandArtifacts(input: {
|
||||
context: BridgeContext;
|
||||
envelope: CommandEnvelope<unknown>;
|
||||
client: ConvexHttpClient;
|
||||
plan: RustBridgeCommandPlan;
|
||||
result: unknown;
|
||||
now?: string;
|
||||
}): Promise<RustBridgeCommandArtifactPlan | null> {
|
||||
const artifacts = await buildRustBridgeCommandArtifactPlan({
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
plan: input.plan,
|
||||
result: input.result,
|
||||
now: input.now,
|
||||
});
|
||||
await persistRustBridgeCommandArtifacts({
|
||||
client: input.client,
|
||||
artifacts,
|
||||
});
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
|
||||
context: BridgeContext;
|
||||
envelope: QueryEnvelope<TPayload>;
|
||||
@@ -653,6 +1105,12 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
mutationReference: unknown,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<TResult>;
|
||||
const runtimeApi = api as typeof api & {
|
||||
mediaAssets: {
|
||||
batchCopy: unknown;
|
||||
batchMove: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
switch (input.plan.functionName) {
|
||||
case "documents:createWithParentReference":
|
||||
@@ -669,6 +1127,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
||||
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
|
||||
...("normalizedMove" in input.plan.argsJson
|
||||
? { normalizedMove: input.plan.argsJson.normalizedMove }
|
||||
: {}),
|
||||
});
|
||||
case "documents:softDelete":
|
||||
return mutation(api.documents.softDelete, {
|
||||
@@ -684,6 +1145,77 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
||||
newId: assertStringArg(input.plan.argsJson, "newId"),
|
||||
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
||||
});
|
||||
case "documents:updateStats":
|
||||
return mutation(api.documents.updateStats, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
wordCount: readRequiredNumberArg(input.plan.argsJson, "wordCount"),
|
||||
characterCount: readRequiredNumberArg(input.plan.argsJson, "characterCount"),
|
||||
blockCount: readRequiredNumberArg(input.plan.argsJson, "blockCount"),
|
||||
todoTotal: readRequiredNumberArg(input.plan.argsJson, "todoTotal"),
|
||||
todoDone: readRequiredNumberArg(input.plan.argsJson, "todoDone"),
|
||||
});
|
||||
case "documents:updateOptions": {
|
||||
const options = readOptionalRecordArg(input.plan.argsJson, "options");
|
||||
if (!options) {
|
||||
throw new DocumentBridgeError("Rust runtime 缺少 options", 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
return mutation(api.documents.updateOptions, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
options: {
|
||||
wideLayout: readOptionalBooleanField(options, "wideLayout"),
|
||||
smallText: readOptionalBooleanField(options, "smallText"),
|
||||
showHeadingNumbers: readOptionalBooleanField(options, "showHeadingNumbers"),
|
||||
showToc: readOptionalBooleanField(options, "showToc"),
|
||||
showStructure: readOptionalBooleanField(options, "showStructure"),
|
||||
protectEditing: readOptionalBooleanField(options, "protectEditing"),
|
||||
showWordCount: readOptionalBooleanField(options, "showWordCount"),
|
||||
collapseBacklinks: readOptionalBooleanField(options, "collapseBacklinks"),
|
||||
pageFont: readOptionalNonEmptyStringField(options, "pageFont"),
|
||||
layoutDensity: readOptionalNonEmptyStringField(options, "layoutDensity"),
|
||||
hideChildPages: readOptionalBooleanField(options, "hideChildPages"),
|
||||
showBlockRefCount: readOptionalBooleanField(options, "showBlockRefCount"),
|
||||
embedDefaultBlockId: readNullableStringField(options, "embedDefaultBlockId"),
|
||||
},
|
||||
});
|
||||
}
|
||||
case "documents:copyTree": {
|
||||
const rawItems = input.plan.argsJson.items;
|
||||
const items = Array.isArray(rawItems)
|
||||
? rawItems
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
||||
.map((item) => ({
|
||||
documentId: assertStringArg(item, "documentId"),
|
||||
recursive: Boolean(item.recursive),
|
||||
}))
|
||||
: [];
|
||||
return mutation(api.documents.copyTree, {
|
||||
items,
|
||||
targetParentId: readOptionalStringArg(input.plan.argsJson, "targetParentId"),
|
||||
});
|
||||
}
|
||||
case "mediaAssets:batchCopy":
|
||||
case "mediaAssets:batchMove":
|
||||
return mutation(
|
||||
input.plan.functionName === "mediaAssets:batchCopy"
|
||||
? runtimeApi.mediaAssets.batchCopy
|
||||
: runtimeApi.mediaAssets.batchMove,
|
||||
{
|
||||
userId: input.plan.actorId,
|
||||
assetIds: readStringArrayArg(input.plan.argsJson, "assetIds"),
|
||||
targetDocumentId: assertStringArg(input.plan.argsJson, "targetDocumentId"),
|
||||
targetSubPath: readOptionalStringArg(input.plan.argsJson, "targetSubPath"),
|
||||
resourceTransferPlan: readOptionalRecordArg(
|
||||
input.plan.argsJson,
|
||||
"resourceTransferPlan",
|
||||
),
|
||||
},
|
||||
);
|
||||
case "mediaAssets:replaceStorageFromUpload":
|
||||
return mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||
userId: assertStringArg(input.plan.argsJson, "userId"),
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
storageId: assertStringArg(input.plan.argsJson, "storageId") as Id<"_storage">,
|
||||
});
|
||||
case "documents:setTemplate":
|
||||
return mutation(api.documents.setTemplate, {
|
||||
id: assertStringArg(input.plan.argsJson, "id"),
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import {
|
||||
recordBridgeCommandArtifacts,
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||
@@ -58,9 +58,12 @@ export async function executeSaveBridgeCommand(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await recordBridgeCommandArtifacts({
|
||||
await recordRustBridgeCommandArtifacts({
|
||||
client,
|
||||
context: input.context,
|
||||
envelope: input.envelope,
|
||||
plan,
|
||||
result: mutationResult,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchKernelFileTreeProjection } from "./projection-client";
|
||||
|
||||
describe("fetchKernelFileTreeProjection", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("通过 3000 同源 file_tree projection endpoint 请求 Rust 搜索 projection", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: {
|
||||
projectionId: "kernel_projection:file_tree:page_root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: "page_root",
|
||||
items: [{ rowId: "asset:table_1" }],
|
||||
edges: [],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await fetchKernelFileTreeProjection({
|
||||
workspaceId: "ws_1",
|
||||
rootNodeId: "page_root",
|
||||
depth: 3,
|
||||
query: " 预算 ",
|
||||
maxResults: 12,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
|
||||
});
|
||||
|
||||
it("失败时透出服务端错误消息", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "获取 projection 失败" }), { status: 502 }),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchKernelFileTreeProjection({ workspaceId: "ws_1" })).rejects.toThrow(
|
||||
"获取 projection 失败",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
|
||||
export type FetchKernelFileTreeProjectionInput = {
|
||||
workspaceId: string;
|
||||
rootNodeId?: string | null;
|
||||
depth?: number | null;
|
||||
query?: string | null;
|
||||
maxResults?: number | null;
|
||||
};
|
||||
|
||||
export async function fetchKernelFileTreeProjection(
|
||||
input: FetchKernelFileTreeProjectionInput,
|
||||
): Promise<KernelFileTreeProjection> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("workspaceId", input.workspaceId);
|
||||
const rootNodeId = input.rootNodeId?.trim();
|
||||
if (rootNodeId) {
|
||||
params.set("rootNodeId", rootNodeId);
|
||||
}
|
||||
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
|
||||
params.set("depth", String(input.depth));
|
||||
}
|
||||
const query = input.query?.trim();
|
||||
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 response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message =
|
||||
typeof payload?.error === "string" ? payload.error : "获取 file_tree projection 失败";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { result?: KernelFileTreeProjection };
|
||||
if (!payload.result) {
|
||||
throw new Error("file_tree projection 响应缺少 result");
|
||||
}
|
||||
return payload.result;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyFileTreeResourceAssets,
|
||||
deleteFileTreeResourceAssets,
|
||||
preflightFileTreeDelete,
|
||||
preflightFileTreeInternalDrop,
|
||||
preflightFileTreePaste,
|
||||
preflightFileTreeUploadTarget,
|
||||
moveFileTreeResourceAssets,
|
||||
renameFileTreeResourceAsset,
|
||||
restoreFileTreeResourceAssets,
|
||||
uploadFileTreeResourceAsset,
|
||||
} from "./resource-command-client";
|
||||
|
||||
describe("file-tree resource command client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("copy/move 应通过统一资源 command client 发送到 media batch route", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ items: [{ id: "asset_1" }] }),
|
||||
} as Response);
|
||||
|
||||
await copyFileTreeResourceAssets({
|
||||
assetIds: ["asset_1", "asset_1", " asset_2 "],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
});
|
||||
await moveFileTreeResourceAssets({
|
||||
assetIds: ["asset_3"],
|
||||
targetDocumentId: "doc_target_2",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/media/batch",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "copy",
|
||||
assetIds: ["asset_1", "asset_2"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/media/batch",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "move",
|
||||
assetIds: ["asset_3"],
|
||||
targetDocumentId: "doc_target_2",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rename/delete/restore 也应复用同一 batch transport 边界", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
} as Response);
|
||||
|
||||
await renameFileTreeResourceAsset({ assetId: "asset_1", newName: "新文件.pdf" });
|
||||
await deleteFileTreeResourceAssets(["asset_1", "asset_2"]);
|
||||
await restoreFileTreeResourceAssets(["asset_3"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([
|
||||
{ action: "rename", assetIds: ["asset_1"], newName: "新文件.pdf" },
|
||||
{ action: "delete", assetIds: ["asset_1", "asset_2"] },
|
||||
{ action: "restore", assetIds: ["asset_3"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("后端返回错误时应抛出稳定 fallback 或服务端消息", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Rust resource preflight rejected" }),
|
||||
} as Response);
|
||||
|
||||
await expect(
|
||||
moveFileTreeResourceAssets({
|
||||
assetIds: ["asset_1"],
|
||||
targetDocumentId: "doc_target",
|
||||
}),
|
||||
).rejects.toThrow("Rust resource preflight rejected");
|
||||
});
|
||||
|
||||
it("upload 应通过统一资源 command client 构造 FormData transport", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ asset: { id: "asset_upload_1" } }),
|
||||
} as Response);
|
||||
const file = new File(["content"], "demo.pdf", { type: "application/pdf" });
|
||||
|
||||
await uploadFileTreeResourceAsset({
|
||||
file,
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_1",
|
||||
mindmapId: "mind_1",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/media/upload",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
);
|
||||
const body = fetchMock.mock.calls[0]?.[1]?.body as FormData;
|
||||
expect(body.get("file")).toBe(file);
|
||||
expect(body.get("workspaceId")).toBe("ws_1");
|
||||
expect(body.get("documentId")).toBe("doc_1");
|
||||
expect(body.get("mindmapId")).toBe("mind_1");
|
||||
});
|
||||
|
||||
it("internal drop preflight 应发送到 tree filetree drop route 并返回 Rust plan", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan: {
|
||||
copy: false,
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: null,
|
||||
targetSubPath: null,
|
||||
rowIds: ["doc:doc_1"],
|
||||
docIds: ["doc_1"],
|
||||
topLevelDocIds: ["doc_1"],
|
||||
copyableAssetIds: [],
|
||||
sourceAssetDocumentIds: [],
|
||||
documentTransferPlan: {
|
||||
action: "move",
|
||||
targetParentId: "doc_target",
|
||||
documentIds: ["doc_1"],
|
||||
topLevelDocumentIds: ["doc_1"],
|
||||
copyItems: [{ documentId: "doc_1", recursive: true }],
|
||||
},
|
||||
resourceTransferPlan: null,
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const plan = await preflightFileTreeInternalDrop({
|
||||
workspaceId: "ws_1",
|
||||
copy: false,
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: null,
|
||||
focusedRowId: null,
|
||||
activeDocumentId: null,
|
||||
rowIds: ["doc:doc_1"],
|
||||
rows: [],
|
||||
documentParents: [],
|
||||
});
|
||||
|
||||
expect(plan.topLevelDocIds).toEqual(["doc_1"]);
|
||||
expect(plan.documentTransferPlan?.topLevelDocumentIds).toEqual(["doc_1"]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/filetree/drop-preflight",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_1",
|
||||
copy: false,
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: null,
|
||||
focusedRowId: null,
|
||||
activeDocumentId: null,
|
||||
rowIds: ["doc:doc_1"],
|
||||
rows: [],
|
||||
documentParents: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("delete preflight 应发送到 tree filetree delete route 并返回 Rust plan", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan: {
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
docIds: ["doc_1"],
|
||||
assetIds: ["asset_1"],
|
||||
assetDocumentIds: ["doc_other"],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const plan = await preflightFileTreeDelete({
|
||||
workspaceId: "ws_1",
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
rows: [],
|
||||
documentParents: [],
|
||||
});
|
||||
|
||||
expect(plan.docIds).toEqual(["doc_1"]);
|
||||
expect(plan.assetIds).toEqual(["asset_1"]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/filetree/delete-preflight",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_1",
|
||||
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||
rows: [],
|
||||
documentParents: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("paste preflight 应发送到 tree filetree paste route 并返回 Rust plan", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan: {
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
docItems: [{ documentId: "doc_1", recursive: false }],
|
||||
copyableAssetIds: ["asset_1"],
|
||||
resourceTransferPlan: {
|
||||
action: "copy",
|
||||
assetIds: ["asset_1"],
|
||||
targetDocumentId: "doc_target",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const plan = await preflightFileTreePaste({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: null,
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocumentId: "doc_active",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
rows: [],
|
||||
});
|
||||
|
||||
expect(plan.docItems).toEqual([{ documentId: "doc_1", recursive: false }]);
|
||||
expect(plan.resourceTransferPlan?.targetSubPath).toBe("mindmaps/mind_1");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/filetree/paste-preflight",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: null,
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocumentId: "doc_active",
|
||||
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||
rows: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("upload target preflight 应发送到 tree filetree upload-target route 并返回 Rust plan", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
plan: {
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const plan = await preflightFileTreeUploadTarget({
|
||||
workspaceId: "ws_fallback",
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset:asset_child_1",
|
||||
focusedRowId: null,
|
||||
activeDocumentId: "doc_active",
|
||||
rows: [],
|
||||
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: "doc_target",
|
||||
targetMindmapId: "mind_1",
|
||||
targetSubPath: "mindmaps/mind_1",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/tree/filetree/upload-target-preflight",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceId: "ws_fallback",
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset:asset_child_1",
|
||||
focusedRowId: null,
|
||||
activeDocumentId: "doc_active",
|
||||
rows: [],
|
||||
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type {
|
||||
FileTreeShellDeletePreflightPayload,
|
||||
FileTreeShellInternalDropPreflightPayload,
|
||||
FileTreeShellPastePreflightPayload,
|
||||
FileTreeShellUploadTargetPreflightPayload,
|
||||
} from "@/lib/file-tree/shell";
|
||||
|
||||
type ResourceCommandAction = "copy" | "move" | "rename" | "delete" | "restore";
|
||||
|
||||
type ResourceCommandErrorPayload = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ResourceCommandResponse = {
|
||||
ok?: boolean;
|
||||
items?: MediaAsset[];
|
||||
asset?: MediaAsset;
|
||||
};
|
||||
|
||||
type TransferResourceAssetsInput = {
|
||||
assetIds: readonly string[];
|
||||
targetDocumentId: string;
|
||||
targetSubPath?: string | null;
|
||||
};
|
||||
|
||||
type RenameResourceAssetInput = {
|
||||
assetId: string;
|
||||
newName: string;
|
||||
};
|
||||
|
||||
type UploadResourceAssetInput = {
|
||||
file: File;
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
mindmapId?: string | null;
|
||||
};
|
||||
|
||||
export type FileTreeInternalDropPreflightPlan = {
|
||||
copy: boolean;
|
||||
targetDocumentId: string;
|
||||
targetMindmapId: string | null;
|
||||
targetSubPath?: string | null;
|
||||
rowIds: string[];
|
||||
docIds: string[];
|
||||
topLevelDocIds: string[];
|
||||
copyableAssetIds: string[];
|
||||
sourceAssetDocumentIds: string[];
|
||||
documentTransferPlan?: {
|
||||
action: "copy" | "move";
|
||||
targetParentId: string;
|
||||
documentIds: string[];
|
||||
topLevelDocumentIds: string[];
|
||||
copyItems: Array<{ documentId: string; recursive: boolean }>;
|
||||
} | null;
|
||||
resourceTransferPlan?: {
|
||||
action: "copy" | "move";
|
||||
assetIds: string[];
|
||||
targetDocumentId: string;
|
||||
targetSubPath?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type FileTreeInternalDropPreflightResponse = {
|
||||
plan?: FileTreeInternalDropPreflightPlan;
|
||||
};
|
||||
|
||||
export type FileTreeDeletePreflightPlan = {
|
||||
rowIds: string[];
|
||||
docIds: string[];
|
||||
assetIds: string[];
|
||||
assetDocumentIds: string[];
|
||||
};
|
||||
|
||||
type FileTreeDeletePreflightResponse = {
|
||||
plan?: FileTreeDeletePreflightPlan;
|
||||
};
|
||||
|
||||
export type FileTreePastePreflightPlan = {
|
||||
targetDocumentId: string;
|
||||
targetMindmapId: string | null;
|
||||
targetSubPath?: string | null;
|
||||
rowIds: string[];
|
||||
docItems: Array<{ documentId: string; recursive: boolean }>;
|
||||
copyableAssetIds: string[];
|
||||
resourceTransferPlan?: {
|
||||
action: "copy";
|
||||
assetIds: string[];
|
||||
targetDocumentId: string;
|
||||
targetSubPath?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type FileTreePastePreflightResponse = {
|
||||
plan?: FileTreePastePreflightPlan;
|
||||
};
|
||||
|
||||
export type FileTreeUploadTargetPreflightPlan = {
|
||||
workspaceId: string;
|
||||
targetDocumentId: string;
|
||||
targetMindmapId: string | null;
|
||||
targetSubPath?: string | null;
|
||||
};
|
||||
|
||||
type FileTreeUploadTargetPreflightResponse = {
|
||||
plan?: FileTreeUploadTargetPreflightPlan;
|
||||
};
|
||||
|
||||
function normalizeAssetIds(assetIds: readonly string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
assetIds
|
||||
.map((assetId) => (typeof assetId === "string" ? assetId.trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function postResourceCommand<TResult>(
|
||||
payload: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
path = "/api/media/batch",
|
||||
): Promise<TResult> {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| TResult
|
||||
| ResourceCommandErrorPayload
|
||||
| null;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return body as TResult;
|
||||
}
|
||||
|
||||
async function postResourceForm<TResult>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
fallbackMessage: string,
|
||||
): Promise<TResult> {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| TResult
|
||||
| ResourceCommandErrorPayload
|
||||
| null;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return body as TResult;
|
||||
}
|
||||
|
||||
function buildTransferPayload(
|
||||
action: Extract<ResourceCommandAction, "copy" | "move">,
|
||||
input: TransferResourceAssetsInput,
|
||||
) {
|
||||
const payload: Record<string, unknown> = {
|
||||
action,
|
||||
assetIds: normalizeAssetIds(input.assetIds),
|
||||
targetDocumentId: input.targetDocumentId,
|
||||
};
|
||||
const targetSubPath = input.targetSubPath?.trim();
|
||||
if (targetSubPath) {
|
||||
payload.targetSubPath = targetSubPath;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function copyFileTreeResourceAssets(
|
||||
input: TransferResourceAssetsInput,
|
||||
): Promise<ResourceCommandResponse> {
|
||||
return postResourceCommand<ResourceCommandResponse>(
|
||||
buildTransferPayload("copy", input),
|
||||
"复制附件失败",
|
||||
);
|
||||
}
|
||||
|
||||
export async function moveFileTreeResourceAssets(
|
||||
input: TransferResourceAssetsInput,
|
||||
): Promise<ResourceCommandResponse> {
|
||||
return postResourceCommand<ResourceCommandResponse>(
|
||||
buildTransferPayload("move", input),
|
||||
"移动附件失败",
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameFileTreeResourceAsset(
|
||||
input: RenameResourceAssetInput,
|
||||
): Promise<ResourceCommandResponse> {
|
||||
return postResourceCommand<ResourceCommandResponse>(
|
||||
{
|
||||
action: "rename",
|
||||
assetIds: normalizeAssetIds([input.assetId]),
|
||||
newName: input.newName,
|
||||
},
|
||||
"重命名失败",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteFileTreeResourceAssets(
|
||||
assetIds: readonly string[],
|
||||
): Promise<ResourceCommandResponse> {
|
||||
return postResourceCommand<ResourceCommandResponse>(
|
||||
{
|
||||
action: "delete",
|
||||
assetIds: normalizeAssetIds(assetIds),
|
||||
},
|
||||
"删除失败",
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreFileTreeResourceAssets(
|
||||
assetIds: readonly string[],
|
||||
): Promise<ResourceCommandResponse> {
|
||||
return postResourceCommand<ResourceCommandResponse>(
|
||||
{
|
||||
action: "restore",
|
||||
assetIds: normalizeAssetIds(assetIds),
|
||||
},
|
||||
"恢复附件失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadFileTreeResourceAsset(
|
||||
input: UploadResourceAssetInput,
|
||||
): Promise<ResourceCommandResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", input.file);
|
||||
formData.append("workspaceId", input.workspaceId);
|
||||
formData.append("documentId", input.documentId);
|
||||
const mindmapId = input.mindmapId?.trim();
|
||||
if (mindmapId) {
|
||||
formData.append("mindmapId", mindmapId);
|
||||
}
|
||||
return postResourceForm<ResourceCommandResponse>(
|
||||
"/api/media/upload",
|
||||
formData,
|
||||
"上传失败",
|
||||
);
|
||||
}
|
||||
|
||||
export async function preflightFileTreeInternalDrop(
|
||||
input: FileTreeShellInternalDropPreflightPayload,
|
||||
): Promise<FileTreeInternalDropPreflightPlan> {
|
||||
const response = await postResourceCommand<FileTreeInternalDropPreflightResponse>(
|
||||
input,
|
||||
"文件树拖放预检失败",
|
||||
"/api/tree/filetree/drop-preflight",
|
||||
);
|
||||
if (!response.plan) {
|
||||
throw new Error("文件树拖放预检失败");
|
||||
}
|
||||
return response.plan;
|
||||
}
|
||||
|
||||
export async function preflightFileTreeDelete(
|
||||
input: FileTreeShellDeletePreflightPayload,
|
||||
): Promise<FileTreeDeletePreflightPlan> {
|
||||
const response = await postResourceCommand<FileTreeDeletePreflightResponse>(
|
||||
input,
|
||||
"文件树删除预检失败",
|
||||
"/api/tree/filetree/delete-preflight",
|
||||
);
|
||||
if (!response.plan) {
|
||||
throw new Error("文件树删除预检失败");
|
||||
}
|
||||
return response.plan;
|
||||
}
|
||||
|
||||
export async function preflightFileTreePaste(
|
||||
input: FileTreeShellPastePreflightPayload,
|
||||
): Promise<FileTreePastePreflightPlan> {
|
||||
const response = await postResourceCommand<FileTreePastePreflightResponse>(
|
||||
input,
|
||||
"文件树粘贴预检失败",
|
||||
"/api/tree/filetree/paste-preflight",
|
||||
);
|
||||
if (!response.plan) {
|
||||
throw new Error("文件树粘贴预检失败");
|
||||
}
|
||||
return response.plan;
|
||||
}
|
||||
|
||||
export async function preflightFileTreeUploadTarget(
|
||||
input: FileTreeShellUploadTargetPreflightPayload,
|
||||
): Promise<FileTreeUploadTargetPreflightPlan> {
|
||||
const response = await postResourceCommand<FileTreeUploadTargetPreflightResponse>(
|
||||
input,
|
||||
"文件树上传目标预检失败",
|
||||
"/api/tree/filetree/upload-target-preflight",
|
||||
);
|
||||
if (!response.plan) {
|
||||
throw new Error("文件树上传目标预检失败");
|
||||
}
|
||||
return response.plan;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
|
||||
import { buildVisibleRows } from "./rows";
|
||||
import { parseFileTreeRowId } from "./types";
|
||||
|
||||
describe("buildVisibleRows", () => {
|
||||
it("按展开状态稳定生成可见行", () => {
|
||||
it("缺少 kernel file_tree items 时不再回退 pageRows + assets 重建对象语义", () => {
|
||||
const a = {
|
||||
access_scope: "private" as const,
|
||||
id: "a",
|
||||
@@ -34,7 +33,17 @@ describe("buildVisibleRows", () => {
|
||||
};
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
pageRows: buildPageTreeProjectionItems([a]),
|
||||
pageRows: [
|
||||
{
|
||||
nodeId: a.id,
|
||||
parentNodeId: null,
|
||||
depth: 0,
|
||||
childCount: 1,
|
||||
position: 0,
|
||||
title: a.title,
|
||||
node: a,
|
||||
},
|
||||
],
|
||||
expanded: new Set(["a"]),
|
||||
assetsByDoc: {
|
||||
a: [
|
||||
@@ -82,40 +91,7 @@ describe("buildVisibleRows", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows.map((r) => `${r.kind}:${r.depth}:${r.rowId}`)).toEqual([
|
||||
"doc:0:doc:a",
|
||||
"index:1:index:a",
|
||||
"asset:1:asset:x",
|
||||
"asset:1:asset:y",
|
||||
"doc:1:doc:b",
|
||||
]);
|
||||
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("输入树包含重复 docId 时应自动去重", () => {
|
||||
const a = {
|
||||
access_scope: "private" as const,
|
||||
id: "a",
|
||||
workspace_id: "w",
|
||||
title: "A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: null,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
pageRows: buildPageTreeProjectionItems([a, a]),
|
||||
expanded: new Set(["a"]),
|
||||
assetsByDoc: {},
|
||||
});
|
||||
|
||||
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
|
||||
@@ -284,175 +260,6 @@ describe("buildVisibleRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
|
||||
const fileTreeItems = [
|
||||
{
|
||||
rowId: "doc:page_root",
|
||||
rowKind: "document",
|
||||
nodeId: "page_root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "根页面",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 3,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:page_root",
|
||||
rowKind: "index",
|
||||
nodeId: "index:page_root",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset_folder",
|
||||
nodeId: "asset-folder:mind_1",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "mindmap",
|
||||
projectionKind: "file_tree",
|
||||
title: "头脑风暴",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "mindmap",
|
||||
documentId: "page_root",
|
||||
assetId: "mind_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "mindmap",
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:asset_child_1",
|
||||
parentNodeId: "asset-folder:mind_1",
|
||||
nodeType: "asset",
|
||||
projectionKind: "file_tree",
|
||||
title: "节点图片.png",
|
||||
depth: 2,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "asset",
|
||||
documentId: "page_root",
|
||||
assetId: "asset_child_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "image",
|
||||
iconHint: "image",
|
||||
},
|
||||
iconHint: "image",
|
||||
},
|
||||
{
|
||||
rowId: "doc:page_child",
|
||||
rowKind: "document",
|
||||
nodeId: "page_child",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "子页面",
|
||||
depth: 1,
|
||||
position: 2,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "page_child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:page_child",
|
||||
rowKind: "index",
|
||||
nodeId: "index:page_child",
|
||||
parentNodeId: "page_child",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 2,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "page_child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const filteredItems = filterKernelFileTreeProjectionItems({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
visibleDocumentIds: new Set(["page_root", "page_child"]),
|
||||
expandedDocumentIds: new Set(["page_root"]),
|
||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||
});
|
||||
|
||||
expect(filteredItems.map((item) => item.rowId)).toEqual([
|
||||
"doc:page_root",
|
||||
"index:page_root",
|
||||
"asset-folder:mind_1",
|
||||
"asset:asset_child_1",
|
||||
"doc:page_child",
|
||||
]);
|
||||
|
||||
const rows = buildVisibleRows({
|
||||
fileTreeItems: filteredItems,
|
||||
expanded: new Set(["page_root"]),
|
||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||
});
|
||||
|
||||
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
|
||||
"doc:doc:page_root",
|
||||
"index:index:page_root",
|
||||
"asset-folder:asset-folder:mind_1",
|
||||
"asset:asset:asset_child_1",
|
||||
"doc:doc:page_child",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileTreeRowId", () => {
|
||||
|
||||
@@ -12,40 +12,6 @@ import type { MediaAsset } from "@/types/media";
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||
|
||||
export function filterKernelFileTreeProjectionItems(input: {
|
||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||
visibleDocumentIds: ReadonlySet<string>;
|
||||
expandedDocumentIds: ReadonlySet<string>;
|
||||
expandedAssetFolderIds?: ReadonlySet<string>;
|
||||
}): KernelFileTreeProjectionItem[] {
|
||||
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
|
||||
|
||||
return input.fileTreeItems.filter((item) => {
|
||||
const docId = getDocIdFromFileTreeItem(item);
|
||||
if (!input.visibleDocumentIds.has(docId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (item.rowKind) {
|
||||
case "document":
|
||||
return true;
|
||||
case "index":
|
||||
case "asset_folder":
|
||||
return input.expandedDocumentIds.has(docId);
|
||||
case "asset": {
|
||||
if (!input.expandedDocumentIds.has(docId)) {
|
||||
return false;
|
||||
}
|
||||
const parentNodeId = String(item.parentNodeId ?? "").trim();
|
||||
if (parentNodeId.startsWith("asset-folder:")) {
|
||||
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildRowsFromKernelFileTreeProjection(input: {
|
||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
@@ -124,16 +90,13 @@ function buildRowsFromKernelFileTreeProjection(input: {
|
||||
|
||||
export function buildVisibleRows({
|
||||
fileTreeItems,
|
||||
pageRows,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId,
|
||||
expandedAssetFolderIds,
|
||||
nodeById,
|
||||
assetById,
|
||||
}: {
|
||||
fileTreeItems?: KernelFileTreeProjectionItem[];
|
||||
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
||||
// 兼容旧调用签名;主路径必须提供 kernel file_tree items,不能再从这些字段重建对象语义。
|
||||
pageRows?: PageTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
assetsByDoc?: Record<string, MediaAsset[]>;
|
||||
@@ -152,77 +115,5 @@ export function buildVisibleRows({
|
||||
});
|
||||
}
|
||||
|
||||
const safePageRows = pageRows ?? [];
|
||||
const safeAssetsByDoc = assetsByDoc ?? {};
|
||||
const rows: FileTreeRow[] = [];
|
||||
|
||||
safePageRows.forEach((item) => {
|
||||
const assets = safeAssetsByDoc[item.nodeId] ?? [];
|
||||
const hasChildren = item.childCount > 0 || assets.length > 0;
|
||||
const isExpanded = expanded.has(item.nodeId);
|
||||
rows.push({
|
||||
kind: "doc",
|
||||
rowId: makeDocRowId(item.nodeId),
|
||||
depth: item.depth,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.parentNodeId,
|
||||
node: item.node,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "index",
|
||||
rowId: makeIndexRowId(item.nodeId),
|
||||
depth: item.depth + 1,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
node: item.node,
|
||||
});
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const children = assetChildrenByAssetId?.[asset.id] ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isExpanded = expandedAssetFolderIds?.has(asset.id) ?? false;
|
||||
rows.push({
|
||||
kind: "asset-folder",
|
||||
rowId: makeAssetFolderRowId(asset.id),
|
||||
depth: item.depth + 1,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
asset,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
if (hasChildren && isExpanded) {
|
||||
children.forEach((child) => {
|
||||
rows.push({
|
||||
kind: "asset",
|
||||
rowId: makeAssetRowId(child.id),
|
||||
depth: item.depth + 2,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
asset: child,
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "asset",
|
||||
rowId: makeAssetRowId(asset.id),
|
||||
depth: item.depth + 1,
|
||||
docId: item.nodeId,
|
||||
parentDocId: item.nodeId,
|
||||
asset,
|
||||
});
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FileTreeSelectionState } from "./selection";
|
||||
import {
|
||||
createEmptyFileTreeSelectionState,
|
||||
materializeRendererSelectionSnapshot,
|
||||
resolveActiveFileTreeSelection,
|
||||
} from "./selection-source";
|
||||
|
||||
function selection(
|
||||
selectedRowIds: string[],
|
||||
anchorRowId: string | null,
|
||||
focusedRowId: string | null,
|
||||
): FileTreeSelectionState {
|
||||
return {
|
||||
selectedRowIds: new Set(selectedRowIds),
|
||||
anchorRowId,
|
||||
focusedRowId,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selection-source", () => {
|
||||
it("rust_family 应优先消费 renderer selection snapshot", () => {
|
||||
const legacySelection = selection(["legacy"], "legacy", "legacy");
|
||||
const rendererSelection = selection(["renderer"], "renderer", "renderer");
|
||||
|
||||
expect(
|
||||
resolveActiveFileTreeSelection({
|
||||
preferRendererSnapshot: true,
|
||||
legacySelection,
|
||||
rendererSelection,
|
||||
}),
|
||||
).toBe(rendererSelection);
|
||||
|
||||
expect(
|
||||
resolveActiveFileTreeSelection({
|
||||
preferRendererSnapshot: false,
|
||||
legacySelection,
|
||||
rendererSelection,
|
||||
}),
|
||||
).toBe(legacySelection);
|
||||
});
|
||||
|
||||
it("renderer event snapshot 只过滤未知 row,不在宿主侧重算 focus/anchor", () => {
|
||||
const snapshot = materializeRendererSelectionSnapshot({
|
||||
payload: {
|
||||
selectedRowIds: ["doc:a", "missing"],
|
||||
anchorRowId: "missing",
|
||||
focusedRowId: "doc:a",
|
||||
},
|
||||
hasRowId: (rowId) => rowId === "doc:a",
|
||||
});
|
||||
|
||||
expect(Array.from(snapshot.selectedRowIds)).toEqual(["doc:a"]);
|
||||
expect(snapshot.anchorRowId).toBeNull();
|
||||
expect(snapshot.focusedRowId).toBe("doc:a");
|
||||
});
|
||||
|
||||
it("空 selection 工厂应返回互不共享的 Set 实例", () => {
|
||||
const a = createEmptyFileTreeSelectionState();
|
||||
const b = createEmptyFileTreeSelectionState();
|
||||
|
||||
a.selectedRowIds.add("doc:a");
|
||||
|
||||
expect(a.selectedRowIds.has("doc:a")).toBe(true);
|
||||
expect(b.selectedRowIds.has("doc:a")).toBe(false);
|
||||
expect(a.selectedRowIds).not.toBe(b.selectedRowIds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FileTreeSelectionState } from "./selection";
|
||||
|
||||
export type FileTreeSelectionSnapshotPayload = {
|
||||
selectedRowIds: readonly string[];
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
};
|
||||
|
||||
export function createEmptyFileTreeSelectionState(): FileTreeSelectionState {
|
||||
return {
|
||||
selectedRowIds: new Set<string>(),
|
||||
anchorRowId: null,
|
||||
focusedRowId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeRendererSelectionSnapshot(input: {
|
||||
payload: FileTreeSelectionSnapshotPayload;
|
||||
hasRowId: (rowId: string) => boolean;
|
||||
}): FileTreeSelectionState {
|
||||
const selectedRowIds = new Set(
|
||||
input.payload.selectedRowIds.filter((rowId) => input.hasRowId(rowId)),
|
||||
);
|
||||
|
||||
return {
|
||||
selectedRowIds,
|
||||
anchorRowId:
|
||||
input.payload.anchorRowId && input.hasRowId(input.payload.anchorRowId)
|
||||
? input.payload.anchorRowId
|
||||
: null,
|
||||
focusedRowId:
|
||||
input.payload.focusedRowId && input.hasRowId(input.payload.focusedRowId)
|
||||
? input.payload.focusedRowId
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveActiveFileTreeSelection(input: {
|
||||
preferRendererSnapshot: boolean;
|
||||
legacySelection: FileTreeSelectionState;
|
||||
rendererSelection: FileTreeSelectionState;
|
||||
}): FileTreeSelectionState {
|
||||
return input.preferRendererSnapshot
|
||||
? input.rendererSelection
|
||||
: input.legacySelection;
|
||||
}
|
||||
@@ -3,9 +3,13 @@ import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import {
|
||||
buildFileTreeShellDeletePreflightPayload,
|
||||
buildFileTreeShellInternalDropPreflightPayload,
|
||||
buildFileTreeShellPastePreflightPayload,
|
||||
buildFileTreeShellUploadTargetPreflightPayload,
|
||||
buildFileTreeShellRowById,
|
||||
buildFileTreeShellVisibleRowIds,
|
||||
computeFileTreeShellDeleteTargets,
|
||||
collectFileTreeShellAssetHints,
|
||||
getOrderedFileTreeShellRows,
|
||||
inferFileTreeShellTargetDocumentId,
|
||||
resolveFileTreeShellMindmapTargetId,
|
||||
@@ -273,22 +277,247 @@ describe("file-tree shell helpers", () => {
|
||||
).toEqual(["doc:doc_root", "asset:pdf_1"]);
|
||||
});
|
||||
|
||||
it("删除目标计算应跳过被父页面覆盖的附件", () => {
|
||||
it("内部拖放 preflight payload 应只收集 Rust 所需的行与父子快照", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
const result = computeFileTreeShellDeleteTargets({
|
||||
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
|
||||
const result = buildFileTreeShellInternalDropPreflightPayload({
|
||||
workspaceId: "ws_1",
|
||||
copy: false,
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset-folder:mind_1",
|
||||
focusedRowId: null,
|
||||
activeDocId: null,
|
||||
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
|
||||
rowById,
|
||||
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
|
||||
parentById: new Map([["doc_root", null]]),
|
||||
parentById: new Map([
|
||||
["doc_root", null],
|
||||
["doc_child", "doc_root"],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.docIds).toEqual(["doc_root"]);
|
||||
expect(result.assetIds).toEqual([]);
|
||||
expect(result.assetHints).toEqual([]);
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_1",
|
||||
copy: false,
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset-folder:mind_1",
|
||||
focusedRowId: null,
|
||||
activeDocumentId: null,
|
||||
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
|
||||
rows: [
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset-folder",
|
||||
documentId: "doc_root",
|
||||
assetId: "mind_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "mindmap",
|
||||
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "asset_child_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "mindmaps/mind_1/assets/node.png",
|
||||
},
|
||||
{
|
||||
rowId: "doc:doc_root",
|
||||
rowKind: "doc",
|
||||
documentId: "doc_root",
|
||||
assetId: null,
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "pdf_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "uploads/guide.pdf",
|
||||
},
|
||||
],
|
||||
documentParents: [
|
||||
{ documentId: "doc_root", parentId: null },
|
||||
{ documentId: "doc_child", parentId: "doc_root" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("删除 preflight payload 应只收集选中行与父子快照", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
const result = buildFileTreeShellDeletePreflightPayload({
|
||||
workspaceId: "ws_1",
|
||||
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
|
||||
rowById,
|
||||
parentById: new Map([
|
||||
["doc_root", null],
|
||||
["doc_child", "doc_root"],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_1",
|
||||
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
|
||||
rows: [
|
||||
{
|
||||
rowId: "doc:doc_root",
|
||||
rowKind: "doc",
|
||||
documentId: "doc_root",
|
||||
assetId: null,
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "pdf_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "uploads/guide.pdf",
|
||||
},
|
||||
],
|
||||
documentParents: [
|
||||
{ documentId: "doc_root", parentId: null },
|
||||
{ documentId: "doc_child", parentId: "doc_root" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("粘贴 preflight payload 应只收集剪贴板行与当前 focused 目标行", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
const result = buildFileTreeShellPastePreflightPayload({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: null,
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocId: "doc_active",
|
||||
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
|
||||
rowById,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_1",
|
||||
targetDocumentId: null,
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocumentId: "doc_active",
|
||||
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
|
||||
rows: [
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset-folder",
|
||||
documentId: "doc_root",
|
||||
assetId: "mind_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "mindmap",
|
||||
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||
},
|
||||
{
|
||||
rowId: "index:doc_root",
|
||||
rowKind: "index",
|
||||
documentId: "doc_root",
|
||||
assetId: null,
|
||||
assetDocumentId: null,
|
||||
assetType: null,
|
||||
storagePath: null,
|
||||
},
|
||||
{
|
||||
rowId: "asset:pdf_1",
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "pdf_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "uploads/guide.pdf",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("上传目标 preflight payload 应只收集目标行、focused 行与文档工作区快照", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
const result = buildFileTreeShellUploadTargetPreflightPayload({
|
||||
workspaceId: "ws_fallback",
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset:asset_child_1",
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocId: "doc_active",
|
||||
rowById,
|
||||
documentWorkspaceById: new Map([
|
||||
["doc_root", "ws_1"],
|
||||
["doc_active", "ws_active"],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
workspaceId: "ws_fallback",
|
||||
targetDocumentId: null,
|
||||
targetRowId: "asset:asset_child_1",
|
||||
focusedRowId: "asset-folder:mind_1",
|
||||
activeDocumentId: "doc_active",
|
||||
rows: [
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
rowKind: "asset",
|
||||
documentId: "doc_root",
|
||||
assetId: "asset_child_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "file",
|
||||
storagePath: "mindmaps/mind_1/assets/node.png",
|
||||
},
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset-folder",
|
||||
documentId: "doc_root",
|
||||
assetId: "mind_1",
|
||||
assetDocumentId: "doc_root",
|
||||
assetType: "mindmap",
|
||||
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||
},
|
||||
],
|
||||
documentWorkspaces: [
|
||||
{ documentId: "doc_root", workspaceId: "ws_1" },
|
||||
{ documentId: "doc_active", workspaceId: "ws_active" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("应能按 assetId 从 shell row map 回填 asset 与 asset-folder 的提示元数据", () => {
|
||||
const rowById = buildFileTreeShellRowById({
|
||||
fileTreeItems: [...fileTreeItems],
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
|
||||
expect(
|
||||
collectFileTreeShellAssetHints({
|
||||
rowById,
|
||||
assetIds: ["mind_1", "pdf_1", "missing"],
|
||||
}).map((asset) => asset.id),
|
||||
).toEqual(["mind_1", "pdf_1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { filterTopLevelDocIds } from "./dnd";
|
||||
|
||||
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
|
||||
|
||||
@@ -21,10 +20,52 @@ export type FileTreeShellRow = {
|
||||
asset: MediaAsset | null;
|
||||
};
|
||||
|
||||
export type FileTreeShellDeleteTargets = {
|
||||
docIds: string[];
|
||||
assetIds: string[];
|
||||
assetHints: MediaAsset[];
|
||||
export type FileTreeShellInternalDropPreflightRow = {
|
||||
rowId: string;
|
||||
rowKind: FileTreeShellRowKind;
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
assetDocumentId: string | null;
|
||||
assetType: string | null;
|
||||
storagePath: string | null;
|
||||
};
|
||||
|
||||
export type FileTreeShellInternalDropPreflightPayload = {
|
||||
workspaceId: string | null;
|
||||
copy: boolean;
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocumentId: string | null;
|
||||
rowIds: string[];
|
||||
rows: FileTreeShellInternalDropPreflightRow[];
|
||||
documentParents: Array<{ documentId: string; parentId: string | null }>;
|
||||
};
|
||||
|
||||
export type FileTreeShellDeletePreflightPayload = {
|
||||
workspaceId: string | null;
|
||||
rowIds: string[];
|
||||
rows: FileTreeShellInternalDropPreflightRow[];
|
||||
documentParents: Array<{ documentId: string; parentId: string | null }>;
|
||||
};
|
||||
|
||||
export type FileTreeShellPastePreflightPayload = {
|
||||
workspaceId: string | null;
|
||||
targetDocumentId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocumentId: string | null;
|
||||
rowIds: string[];
|
||||
rows: FileTreeShellInternalDropPreflightRow[];
|
||||
};
|
||||
|
||||
export type FileTreeShellUploadTargetPreflightPayload = {
|
||||
workspaceId: string | null;
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocumentId: string | null;
|
||||
rows: FileTreeShellInternalDropPreflightRow[];
|
||||
documentWorkspaces: Array<{ documentId: string; workspaceId: string | null }>;
|
||||
};
|
||||
|
||||
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
|
||||
@@ -133,65 +174,6 @@ export function resolveFileTreeShellMindmapTargetId(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function computeFileTreeShellDeleteTargets(input: {
|
||||
visibleRowIds: readonly string[];
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
selectedRowIds: ReadonlySet<string>;
|
||||
parentById: Map<string, string | null>;
|
||||
}): FileTreeShellDeleteTargets {
|
||||
const rows = getOrderedFileTreeShellRows({
|
||||
rowIds: input.selectedRowIds,
|
||||
visibleRowIds: input.visibleRowIds,
|
||||
rowById: input.rowById,
|
||||
});
|
||||
|
||||
const docCandidates: string[] = [];
|
||||
const assetCandidates: string[] = [];
|
||||
const assetDocIdByAssetId = new Map<string, string>();
|
||||
const assetHintById = new Map<string, MediaAsset>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.rowKind === "doc" || row.rowKind === "index") {
|
||||
docCandidates.push(row.documentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
|
||||
assetCandidates.push(row.assetId);
|
||||
assetDocIdByAssetId.set(row.assetId, row.documentId);
|
||||
if (row.asset) {
|
||||
assetHintById.set(row.assetId, row.asset);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
|
||||
const docIdSet = new Set(docIds);
|
||||
const seenAssets = new Set<string>();
|
||||
const assetIds: string[] = [];
|
||||
const assetHints: MediaAsset[] = [];
|
||||
|
||||
assetCandidates.forEach((assetId) => {
|
||||
if (!assetId || seenAssets.has(assetId)) {
|
||||
return;
|
||||
}
|
||||
seenAssets.add(assetId);
|
||||
|
||||
const ownerDocId = assetDocIdByAssetId.get(assetId);
|
||||
if (ownerDocId && docIdSet.has(ownerDocId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
assetIds.push(assetId);
|
||||
const assetHint = assetHintById.get(assetId);
|
||||
if (assetHint) {
|
||||
assetHints.push(assetHint);
|
||||
}
|
||||
});
|
||||
|
||||
return { docIds, assetIds, assetHints };
|
||||
}
|
||||
|
||||
export function inferFileTreeShellTargetDocumentId(input: {
|
||||
focusedRowId: string | null;
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
@@ -205,3 +187,202 @@ export function inferFileTreeShellTargetDocumentId(input: {
|
||||
}
|
||||
return input.activeDocId || null;
|
||||
}
|
||||
|
||||
function normalizeShellText(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function buildFileTreeShellDropPreflightRow(
|
||||
row: FileTreeShellRow,
|
||||
): FileTreeShellInternalDropPreflightRow {
|
||||
return {
|
||||
rowId: row.rowId,
|
||||
rowKind: row.rowKind,
|
||||
documentId: normalizeShellText(row.documentId),
|
||||
assetId: normalizeShellText(row.assetId),
|
||||
assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
|
||||
assetType: normalizeShellText(row.asset?.asset_type ?? null),
|
||||
storagePath: normalizeShellText(row.asset?.storage_path ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFileTreeShellInternalDropPreflightPayload(input: {
|
||||
workspaceId: string | null;
|
||||
copy: boolean;
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocId: string | null;
|
||||
rowIds: readonly string[];
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
parentById: Map<string, string | null>;
|
||||
}): FileTreeShellInternalDropPreflightPayload {
|
||||
const rowIdSet = new Set<string>();
|
||||
const appendRowId = (value: string | null | undefined) => {
|
||||
const rowId = normalizeShellText(value);
|
||||
if (rowId) {
|
||||
rowIdSet.add(rowId);
|
||||
}
|
||||
};
|
||||
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
|
||||
const targetRowId = normalizeShellText(input.targetRowId);
|
||||
const focusedRowId = normalizeShellText(input.focusedRowId);
|
||||
appendRowId(targetRowId);
|
||||
appendRowId(focusedRowId);
|
||||
rowIds.forEach(appendRowId);
|
||||
|
||||
const rows = Array.from(rowIdSet)
|
||||
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||
.map(buildFileTreeShellDropPreflightRow);
|
||||
|
||||
return {
|
||||
workspaceId: normalizeShellText(input.workspaceId),
|
||||
copy: input.copy,
|
||||
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||
targetRowId,
|
||||
focusedRowId,
|
||||
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||
rowIds,
|
||||
rows,
|
||||
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
|
||||
documentId,
|
||||
parentId: normalizeShellText(parentId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFileTreeShellDeletePreflightPayload(input: {
|
||||
workspaceId: string | null;
|
||||
rowIds: readonly string[];
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
parentById: Map<string, string | null>;
|
||||
}): FileTreeShellDeletePreflightPayload {
|
||||
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
|
||||
const rowIdSet = new Set<string>();
|
||||
rowIds.forEach((rowId) => {
|
||||
const normalized = normalizeShellText(rowId);
|
||||
if (normalized) {
|
||||
rowIdSet.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
const rows = Array.from(rowIdSet)
|
||||
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||
.map(buildFileTreeShellDropPreflightRow);
|
||||
|
||||
return {
|
||||
workspaceId: normalizeShellText(input.workspaceId),
|
||||
rowIds,
|
||||
rows,
|
||||
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
|
||||
documentId,
|
||||
parentId: normalizeShellText(parentId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFileTreeShellPastePreflightPayload(input: {
|
||||
workspaceId: string | null;
|
||||
targetDocumentId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocId: string | null;
|
||||
rowIds: readonly string[];
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
}): FileTreeShellPastePreflightPayload {
|
||||
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
|
||||
const rowIdSet = new Set<string>();
|
||||
const focusedRowId = normalizeShellText(input.focusedRowId);
|
||||
if (focusedRowId) {
|
||||
rowIdSet.add(focusedRowId);
|
||||
}
|
||||
rowIds.forEach((rowId) => {
|
||||
const normalized = normalizeShellText(rowId);
|
||||
if (normalized) {
|
||||
rowIdSet.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
const rows = Array.from(rowIdSet)
|
||||
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||
.map(buildFileTreeShellDropPreflightRow);
|
||||
|
||||
return {
|
||||
workspaceId: normalizeShellText(input.workspaceId),
|
||||
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||
focusedRowId,
|
||||
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||
rowIds,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFileTreeShellUploadTargetPreflightPayload(input: {
|
||||
workspaceId: string | null;
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
activeDocId: string | null;
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
documentWorkspaceById: Map<string, string | null>;
|
||||
}): FileTreeShellUploadTargetPreflightPayload {
|
||||
const rowIdSet = new Set<string>();
|
||||
const appendRowId = (value: string | null | undefined) => {
|
||||
const rowId = normalizeShellText(value);
|
||||
if (rowId) {
|
||||
rowIdSet.add(rowId);
|
||||
}
|
||||
};
|
||||
const targetRowId = normalizeShellText(input.targetRowId);
|
||||
const focusedRowId = normalizeShellText(input.focusedRowId);
|
||||
appendRowId(targetRowId);
|
||||
appendRowId(focusedRowId);
|
||||
|
||||
const rows = Array.from(rowIdSet)
|
||||
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||
.map(buildFileTreeShellDropPreflightRow);
|
||||
|
||||
return {
|
||||
workspaceId: normalizeShellText(input.workspaceId),
|
||||
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||
targetRowId,
|
||||
focusedRowId,
|
||||
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||
rows,
|
||||
documentWorkspaces: Array.from(input.documentWorkspaceById.entries()).map(
|
||||
([documentId, workspaceId]) => ({
|
||||
documentId,
|
||||
workspaceId: normalizeShellText(workspaceId),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectFileTreeShellAssetHints(input: {
|
||||
rowById: Map<string, FileTreeShellRow>;
|
||||
assetIds: readonly string[];
|
||||
}): MediaAsset[] {
|
||||
const hints: MediaAsset[] = [];
|
||||
const seen = new Set<string>();
|
||||
input.assetIds.forEach((assetId) => {
|
||||
const normalized = normalizeShellText(assetId);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
const asset =
|
||||
input.rowById.get(`asset:${normalized}`)?.asset ??
|
||||
input.rowById.get(`asset-folder:${normalized}`)?.asset ??
|
||||
null;
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
hints.push(asset);
|
||||
});
|
||||
return hints;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { resolveKernelFileTreeProjection } from "./kernel-file-tree";
|
||||
|
||||
const mockBuildDocumentBridgeContextWithActor = vi.fn();
|
||||
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||
const mockExecuteRustBridgeQuery = vi.fn();
|
||||
|
||||
vi.mock("@/lib/documents/bridge", () => ({
|
||||
buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
|
||||
mockBuildDocumentBridgeContextWithActor(...args),
|
||||
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
|
||||
}));
|
||||
|
||||
describe("resolveKernelFileTreeProjection", () => {
|
||||
beforeEach(() => {
|
||||
mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
|
||||
requestId: "req_1",
|
||||
traceId: "trace_1",
|
||||
});
|
||||
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
|
||||
mockExecuteRustBridgeQuery.mockReset().mockResolvedValue({
|
||||
projectionId: "kernel_projection:file_tree:page_root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: "page_root",
|
||||
items: [],
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("把搜索词传入 Rust kernel.project_view,而不是交给宿主裁剪", async () => {
|
||||
await resolveKernelFileTreeProjection({
|
||||
client: { query: vi.fn() } as unknown as ConvexHttpClient,
|
||||
request: new Request("http://127.0.0.1:3000/api/tree/projections/file"),
|
||||
workspaceId: "ws_1",
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "user_1",
|
||||
sessionId: null,
|
||||
},
|
||||
dataset: {
|
||||
active_workspace_id: "ws_1",
|
||||
documents: [],
|
||||
},
|
||||
rootNodeId: "page_root",
|
||||
depth: 2,
|
||||
query: " 预算 ",
|
||||
maxResults: 12,
|
||||
});
|
||||
|
||||
expect(mockBuildDocumentQueryEnvelope).toHaveBeenCalledWith({
|
||||
name: "kernel.project_view",
|
||||
payload: expect.objectContaining({
|
||||
projection: "file_tree",
|
||||
workspaceId: "ws_1",
|
||||
rootNodeId: "page_root",
|
||||
depth: 2,
|
||||
query: "预算",
|
||||
maxResults: 12,
|
||||
}),
|
||||
});
|
||||
expect(mockExecuteRustBridgeQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,19 @@ export async function resolveKernelFileTreeProjection(input: {
|
||||
dataset: SidebarDatasetListQueryResult;
|
||||
rootNodeId?: string | null;
|
||||
depth?: number | null;
|
||||
query?: string | null;
|
||||
maxResults?: number | null;
|
||||
}): Promise<KernelFileTreeProjection> {
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request: input.request,
|
||||
actor: input.actor,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const query = input.query?.trim() || null;
|
||||
const maxResults =
|
||||
typeof input.maxResults === "number" && Number.isFinite(input.maxResults)
|
||||
? Math.max(1, Math.floor(input.maxResults))
|
||||
: null;
|
||||
|
||||
return executeRustBridgeQuery<KernelFileTreeProjection>({
|
||||
context,
|
||||
@@ -32,6 +39,8 @@ export async function resolveKernelFileTreeProjection(input: {
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: input.rootNodeId ?? null,
|
||||
depth: input.depth ?? null,
|
||||
query,
|
||||
maxResults,
|
||||
includeEdges: true,
|
||||
includeContent: false,
|
||||
nodeTypes: ["page"],
|
||||
|
||||
@@ -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