feat(tree): close rust family shell cutover
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertDocumentMoveWriteOperationMatches,
|
||||
buildDocumentMoveOrderPlanFromDocuments,
|
||||
} from "../../../convex/_utils/documentMoveOrder";
|
||||
|
||||
describe("documentMoveOrder Convex helper", () => {
|
||||
it("assertDocumentMoveWriteOperationMatches 应只接受 Rust tree write operation,并返回内部 move plan", () => {
|
||||
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",
|
||||
},
|
||||
],
|
||||
documentId: "doc_b",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
const normalized = assertDocumentMoveWriteOperationMatches(
|
||||
{
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
toParentId: null,
|
||||
requestedSortOrder: 0,
|
||||
normalizedSortOrder: 0,
|
||||
patches: [
|
||||
{
|
||||
documentId: "doc_b",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
moved: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
actual,
|
||||
);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
toParentId: null,
|
||||
requestedSortOrder: 0,
|
||||
normalizedSortOrder: 0,
|
||||
patches: [
|
||||
{
|
||||
documentId: "doc_b",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
moved: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => assertDocumentMoveWriteOperationMatches({ ...actual, operation: "documents.move" }, actual)).toThrow(
|
||||
"Rust move write operation 与 Convex 当前排序状态不一致",
|
||||
);
|
||||
});
|
||||
|
||||
it("assertDocumentMoveWriteOperationMatches 应拒绝缺少正式 write operation 外壳的旧 move plan", () => {
|
||||
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",
|
||||
},
|
||||
],
|
||||
documentId: "doc_b",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
expect(() => assertDocumentMoveWriteOperationMatches(actual, actual)).toThrow(
|
||||
"Rust move write operation 与 Convex 当前排序状态不一致",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertDocumentMoveOrderPlanMatches,
|
||||
assertDocumentMoveWriteOperationMatches,
|
||||
buildDocumentMoveOrderPlanFromDocuments,
|
||||
} from "../../../convex/_utils/documentMoveOrder";
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("documentMoveOrder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => {
|
||||
it("treeWriteOperation 与当前排序状态不一致时拒绝执行", () => {
|
||||
const actual = buildDocumentMoveOrderPlanFromDocuments({
|
||||
documents: [
|
||||
{
|
||||
@@ -89,8 +89,13 @@ describe("documentMoveOrder", () => {
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
assertDocumentMoveOrderPlanMatches(
|
||||
assertDocumentMoveWriteOperationMatches(
|
||||
{
|
||||
family: "tree",
|
||||
schema: "mnote.tree.write_operation",
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: "ws_1",
|
||||
...actual,
|
||||
patches: actual.patches.map((patch) =>
|
||||
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
|
||||
@@ -98,6 +103,6 @@ describe("documentMoveOrder", () => {
|
||||
},
|
||||
actual,
|
||||
),
|
||||
).toThrow("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
).toThrow("Rust move write operation 与 Convex 当前排序状态不一致");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,10 @@ import {
|
||||
recordBridgeCommandFailureArtifacts,
|
||||
recordRustBridgeCommandArtifacts,
|
||||
} from "@/lib/documents/bridge-log";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import {
|
||||
executeRustBridgeMutationTransport,
|
||||
resolveRustBridgeCommandPlan,
|
||||
} from "@/lib/documents/rust-runtime";
|
||||
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
||||
import type { Json } from "@/types/supabase";
|
||||
|
||||
export type DocumentCreatePayload = {
|
||||
documentId: string;
|
||||
@@ -82,10 +79,7 @@ export type DocumentEmbedPayload = {
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
revision: number | null;
|
||||
content: Json;
|
||||
conflictDetectionKey: string | null;
|
||||
snapshotCapturedAt: string | null;
|
||||
blockCount: number | null;
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
anchorBlockId: string | null;
|
||||
@@ -275,47 +269,20 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
||||
}
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: normalizedTargetId });
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||
const anchorId = trimOrNull((targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id);
|
||||
const anchorIndex =
|
||||
anchorId
|
||||
? currentBlocks.findIndex(
|
||||
(block) => typeof block === "object" && block !== null && String((block as { id?: string }).id ?? "") === anchorId,
|
||||
)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks.slice(0, insertIndex),
|
||||
{
|
||||
id: safeRandomId(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: normalizedSourceId,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
...currentBlocks.slice(insertIndex),
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
const workspaceId = normalizeWorkspaceId(sourceDoc.workspace_id) ?? normalizeWorkspaceId((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||
const context = await buildRuntimeContext(request, workspaceId);
|
||||
const embedPayload: DocumentEmbedPayload = {
|
||||
...buildDocumentSavePayload({
|
||||
documentId: normalizedTargetId,
|
||||
workspaceId,
|
||||
revision:
|
||||
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
|
||||
? targetContent.revision
|
||||
: null,
|
||||
content: payload,
|
||||
conflictDetectionKey:
|
||||
typeof targetContent.conflict_detection_key === "string"
|
||||
? targetContent.conflict_detection_key
|
||||
: null,
|
||||
blockCount: nextBlocks.length,
|
||||
}),
|
||||
documentId: normalizedTargetId,
|
||||
workspaceId,
|
||||
revision:
|
||||
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
|
||||
? targetContent.revision
|
||||
: null,
|
||||
conflictDetectionKey:
|
||||
typeof targetContent.conflict_detection_key === "string"
|
||||
? targetContent.conflict_detection_key
|
||||
: null,
|
||||
sourceDocumentId: normalizedSourceId,
|
||||
targetDocumentId: normalizedTargetId,
|
||||
anchorBlockId: anchorId,
|
||||
@@ -324,6 +291,16 @@ export async function executeDocumentEmbedBridgeCommand(request: Request): Promi
|
||||
name: "documents.embed",
|
||||
payload: embedPayload,
|
||||
context,
|
||||
preflightData: {
|
||||
pageAggregateEmbed: {
|
||||
sourceDocumentId: normalizedSourceId,
|
||||
sourceTitle: sourceDoc.title ?? "无标题",
|
||||
targetDocumentId: normalizedTargetId,
|
||||
targetContent: targetContent.content,
|
||||
anchorBlockId: anchorId,
|
||||
blockId: safeRandomId(),
|
||||
},
|
||||
},
|
||||
target: {
|
||||
workspaceId,
|
||||
pageId: normalizedTargetId,
|
||||
|
||||
@@ -373,14 +373,14 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
||||
export async function handleDocumentMoveRequest(request: Request): Promise<NextResponse> {
|
||||
assertServerEnvironment();
|
||||
const requestClone = request.clone();
|
||||
let normalizedMove: NormalizedDocumentMovePayload | null = null;
|
||||
let movePayload: NormalizedDocumentMovePayload | null = null;
|
||||
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
|
||||
let failureAuthUserId: string | null = null;
|
||||
let failureSourceDocument: MovePreflightDocument | null = null;
|
||||
try {
|
||||
const payload = (await request.json()) as MovePayload;
|
||||
normalizedMove = normalizeDocumentMovePayload(payload);
|
||||
const documentId = normalizedMove.documentId;
|
||||
movePayload = normalizeDocumentMovePayload(payload);
|
||||
const documentId = movePayload.documentId;
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
@@ -402,14 +402,14 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
const movePreflight = await buildMovePreflight({
|
||||
client,
|
||||
sourceDocument: failureSourceDocument,
|
||||
targetParentId: normalizedMove.parentId,
|
||||
targetParentId: movePayload.parentId,
|
||||
});
|
||||
const envelope = buildDocumentCommandEnvelope({
|
||||
name: "documents.move",
|
||||
payload: {
|
||||
documentId,
|
||||
parentId: normalizedMove.parentId,
|
||||
sortOrder: normalizedMove.sortOrder,
|
||||
parentId: movePayload.parentId,
|
||||
sortOrder: movePayload.sortOrder,
|
||||
movePreflight,
|
||||
},
|
||||
preflightData: movePreflight,
|
||||
@@ -438,7 +438,7 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackMove = normalizedMove
|
||||
const fallbackMove = movePayload
|
||||
?? normalizeDocumentMovePayload(
|
||||
(await requestClone.json().catch(() => ({}))) as MovePayload,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
executeRustBridgeMutationTransport,
|
||||
materializeRustTreeStreamDelta,
|
||||
readRustTreeDomainEventPlan,
|
||||
readRustTreeDomainEventPlans,
|
||||
readRustTreeDomainEventType,
|
||||
type RustBridgeCommandPlan,
|
||||
} from "./rust-runtime";
|
||||
@@ -83,7 +84,7 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
||||
|
||||
describe("executeRustBridgeMutationTransport", () => {
|
||||
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
|
||||
const normalizedMove = {
|
||||
const movePlan = {
|
||||
documentId: "doc_b",
|
||||
fromParentId: "source",
|
||||
toParentId: "target",
|
||||
@@ -104,7 +105,7 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
schemaVersion: 1,
|
||||
operation: "tree.subtree.move.write",
|
||||
workspaceId: "ws_1",
|
||||
...normalizedMove,
|
||||
...movePlan,
|
||||
};
|
||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
@@ -122,7 +123,6 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
id: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
},
|
||||
};
|
||||
@@ -137,7 +137,6 @@ describe("executeRustBridgeMutationTransport", () => {
|
||||
id: "doc_b",
|
||||
parentId: "target",
|
||||
sortOrder: 0,
|
||||
normalizedMove,
|
||||
treeWriteOperation,
|
||||
});
|
||||
});
|
||||
@@ -645,7 +644,7 @@ describe("readRustTreeDomainEventType", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("应保留 Rust formal domainEventPlan payload schema", () => {
|
||||
it("应保留 Rust formal domainEventPlan payload schema,并拆出 snapshot 独立事件", () => {
|
||||
const plan: RustBridgeCommandPlan = {
|
||||
kind: "command",
|
||||
commandName: "page.body.save",
|
||||
@@ -668,11 +667,6 @@ describe("readRustTreeDomainEventType", () => {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
@@ -687,6 +681,57 @@ describe("readRustTreeDomainEventType", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
domainEventPlans: [
|
||||
{
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "document.snapshot.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -700,11 +745,6 @@ describe("readRustTreeDomainEventType", () => {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
@@ -719,6 +759,22 @@ describe("readRustTreeDomainEventType", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(readRustTreeDomainEventPlans(plan).map((eventPlan) => eventPlan.eventType)).toEqual([
|
||||
"page.body.saved",
|
||||
"document.snapshot.saved",
|
||||
]);
|
||||
expect(readRustTreeDomainEventPlans(plan)[0]?.payload).not.toHaveProperty("snapshot");
|
||||
expect(readRustTreeDomainEventPlans(plan)[1]?.payload).toMatchObject({
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -796,11 +852,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
@@ -815,6 +866,57 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
domainEventPlans: [
|
||||
{
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "page.body.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
family: "tree",
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "document.snapshot.saved",
|
||||
payload: {
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
},
|
||||
streamDeltaHint: {
|
||||
family: "tree",
|
||||
kind: "resync_required",
|
||||
args: {
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
result: {
|
||||
@@ -842,6 +944,10 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
});
|
||||
expect(artifactPlan?.commandLog.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvent?.commandId).toBe("cmd_artifact_1");
|
||||
expect(artifactPlan?.domainEvents?.map((event) => event.eventType)).toEqual([
|
||||
"page.body.saved",
|
||||
"document.snapshot.saved",
|
||||
]);
|
||||
expect(artifactPlan?.domainEvent).toMatchObject({
|
||||
id: "evt_cmd_artifact_1",
|
||||
eventType: "page.body.saved",
|
||||
@@ -854,11 +960,6 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
blocks: {
|
||||
ids: ["block_1"],
|
||||
count: 1,
|
||||
@@ -870,5 +971,30 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(artifactPlan?.domainEvent?.payload).not.toHaveProperty("snapshot");
|
||||
expect(artifactPlan?.domainEvents?.[1]).toMatchObject({
|
||||
id: "evt_cmd_artifact_1_02_document_snapshot_saved",
|
||||
eventType: "document.snapshot.saved",
|
||||
payload: {
|
||||
schema: "mnote.tree.domain_event",
|
||||
schemaVersion: 1,
|
||||
eventType: "document.snapshot.saved",
|
||||
command_id: "cmd_artifact_1",
|
||||
page: {
|
||||
id: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
},
|
||||
snapshot: {
|
||||
version: 8,
|
||||
contentHash: "fnv1a64:d039f8f3496411e8",
|
||||
updatedAt: null,
|
||||
},
|
||||
streamDelta: {
|
||||
op: "resync_required",
|
||||
reason: "page_body_saved",
|
||||
pageId: "doc_1",
|
||||
},
|
||||
},
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -161,6 +161,7 @@ export type RustBridgeDomainEventArtifactPlan = {
|
||||
export type RustBridgeCommandArtifactPlan = {
|
||||
commandLog: RustBridgeCommandLogArtifactPlan;
|
||||
domainEvent: RustBridgeDomainEventArtifactPlan | null;
|
||||
domainEvents?: RustBridgeDomainEventArtifactPlan[];
|
||||
};
|
||||
|
||||
export type RustBridgeToolPlanStep = {
|
||||
@@ -555,6 +556,14 @@ function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string)
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function readRequiredRecordArg(argsJson: Record<string, unknown>, field: string) {
|
||||
const value = argsJson[field];
|
||||
if (isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||
}
|
||||
|
||||
function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
|
||||
const value = source[field];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
@@ -670,6 +679,24 @@ export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string
|
||||
|
||||
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
|
||||
const eventPlan = plan.argsJson.domainEventPlan;
|
||||
return normalizeRustTreeDomainEventPlan(eventPlan);
|
||||
}
|
||||
|
||||
export function readRustTreeDomainEventPlans(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan[] {
|
||||
const eventPlans = plan.argsJson.domainEventPlans;
|
||||
if (Array.isArray(eventPlans)) {
|
||||
const normalized = eventPlans
|
||||
.map((eventPlan) => normalizeRustTreeDomainEventPlan(eventPlan))
|
||||
.filter((eventPlan): eventPlan is RustTreeDomainEventPlan => Boolean(eventPlan));
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const single = readRustTreeDomainEventPlan(plan);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
function normalizeRustTreeDomainEventPlan(eventPlan: unknown): RustTreeDomainEventPlan | null {
|
||||
if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
|
||||
return null;
|
||||
}
|
||||
@@ -715,6 +742,24 @@ export function materializeRustTreeDomainEventPlan(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeRustTreeDomainEventPlans(input: {
|
||||
plan: RustBridgeCommandPlan;
|
||||
result: unknown;
|
||||
streamDelta?: RustTreeStreamDelta | null;
|
||||
}): RustTreeDomainEventPlan[] {
|
||||
const eventPlans = readRustTreeDomainEventPlans(input.plan);
|
||||
const streamDelta =
|
||||
input.streamDelta ??
|
||||
materializeRustTreeStreamDelta({
|
||||
plan: input.plan,
|
||||
result: input.result,
|
||||
});
|
||||
return eventPlans.map((eventPlan) => ({
|
||||
...eventPlan,
|
||||
...(streamDelta ? { streamDelta } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function materializeRustTreeStreamDelta(input: {
|
||||
plan: RustBridgeCommandPlan;
|
||||
result: unknown;
|
||||
@@ -882,8 +927,14 @@ export async function persistRustBridgeCommandArtifacts(input: {
|
||||
) => 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>);
|
||||
const domainEvents =
|
||||
artifacts.domainEvents && artifacts.domainEvents.length > 0
|
||||
? artifacts.domainEvents
|
||||
: artifacts.domainEvent
|
||||
? [artifacts.domainEvent]
|
||||
: [];
|
||||
for (const domainEvent of domainEvents) {
|
||||
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, domainEvent as unknown as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,12 +1202,7 @@ 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 }
|
||||
: {}),
|
||||
...("treeWriteOperation" in input.plan.argsJson
|
||||
? { treeWriteOperation: input.plan.argsJson.treeWriteOperation }
|
||||
: {}),
|
||||
treeWriteOperation: readRequiredRecordArg(input.plan.argsJson, "treeWriteOperation"),
|
||||
});
|
||||
case "documents:softDelete":
|
||||
return mutation(api.documents.softDelete, {
|
||||
|
||||
@@ -21,6 +21,23 @@ describe("fetchKernelFileTreeProjection", () => {
|
||||
rootNodeId: "page_root",
|
||||
items: [{ rowId: "asset:table_1" }],
|
||||
edges: [],
|
||||
meta: {
|
||||
search: {
|
||||
indexingVisibility: {
|
||||
schema: "mnote.file_tree.indexing_visibility",
|
||||
schemaVersion: 1,
|
||||
source: "kernel.project_view",
|
||||
status: "visible",
|
||||
requestKey: "page_root:预算",
|
||||
indexedResourceKinds: ["document", "index", "asset"],
|
||||
visibleResourceKinds: ["document", "index", "asset"],
|
||||
metrics: {
|
||||
visibleRows: 1,
|
||||
visibleEdges: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
@@ -44,6 +61,11 @@ describe("fetchKernelFileTreeProjection", () => {
|
||||
}),
|
||||
);
|
||||
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
|
||||
expect(result.meta?.search?.indexingVisibility).toMatchObject({
|
||||
schema: "mnote.file_tree.indexing_visibility",
|
||||
status: "visible",
|
||||
requestKey: "page_root:预算",
|
||||
});
|
||||
});
|
||||
|
||||
it("固定 file_tree 搜索语义边界:命中数先截断,祖先补全不计入 maxResults", () => {
|
||||
|
||||
@@ -29,12 +29,36 @@ export type KernelFileTreeProjectionEdge = {
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type KernelFileTreeIndexingVisibility = {
|
||||
schema: "mnote.file_tree.indexing_visibility";
|
||||
schemaVersion: 1;
|
||||
source: "kernel.project_view";
|
||||
status: "visible" | "stale" | "refreshing" | "unknown";
|
||||
requestKey: string | null;
|
||||
indexedResourceKinds: string[];
|
||||
visibleResourceKinds: string[];
|
||||
metrics: {
|
||||
visibleRows: number;
|
||||
visibleEdges: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type KernelFileTreeProjection = {
|
||||
projectionId: string;
|
||||
projection: "file_tree";
|
||||
rootNodeId: string | null;
|
||||
items: KernelFileTreeProjectionItem[];
|
||||
edges: KernelFileTreeProjectionEdge[];
|
||||
meta?: {
|
||||
search?: {
|
||||
query?: string | null;
|
||||
maxResults?: number | null;
|
||||
maxResultsRule?: "matches_only_before_ancestor_completion";
|
||||
ancestorCompletion?: "include_all_ancestors_after_match_truncation";
|
||||
ordering?: "kernel_file_tree_preorder";
|
||||
indexingVisibility?: KernelFileTreeIndexingVisibility;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BuildKernelFileTreeProjectionInput = {
|
||||
@@ -574,5 +598,27 @@ export function buildKernelFileTreeProjection(
|
||||
rootNodeId,
|
||||
items,
|
||||
edges,
|
||||
meta: {
|
||||
search: {
|
||||
query: null,
|
||||
maxResults: null,
|
||||
maxResultsRule: "matches_only_before_ancestor_completion",
|
||||
ancestorCompletion: "include_all_ancestors_after_match_truncation",
|
||||
ordering: "kernel_file_tree_preorder",
|
||||
indexingVisibility: {
|
||||
schema: "mnote.file_tree.indexing_visibility",
|
||||
schemaVersion: 1,
|
||||
source: "kernel.project_view",
|
||||
status: "visible",
|
||||
requestKey: null,
|
||||
indexedResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
visibleResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
|
||||
metrics: {
|
||||
visibleRows: items.length,
|
||||
visibleEdges: edges.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary";
|
||||
|
||||
describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
|
||||
it("固定 3000 route 的 thin proxy 与 compat pending 边界", () => {
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({
|
||||
schema: "mnote.tree.3000_route_boundary",
|
||||
schemaVersion: 1,
|
||||
publicEntry: "3000",
|
||||
});
|
||||
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.routes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "tree.commands",
|
||||
role: "next-thin-proxy",
|
||||
route: "/api/tree/commands",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "tree.stream",
|
||||
role: "next-thin-proxy",
|
||||
route: "/api/tree/stream",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "tree.shell.debug",
|
||||
role: "compat-pending",
|
||||
route: "/api/tree/shell",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move");
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain(
|
||||
"Rust command result 回包整形",
|
||||
);
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
|
||||
"新页面 scaffold 文件创建",
|
||||
);
|
||||
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
export type TreeRouteBoundaryRole =
|
||||
| "rust-owned"
|
||||
| "next-thin-proxy"
|
||||
| "browser-substrate"
|
||||
| "compat-pending";
|
||||
|
||||
export type TreeRouteBoundaryItem = {
|
||||
id: string;
|
||||
role: TreeRouteBoundaryRole;
|
||||
route: string;
|
||||
owner: "rust-runtime" | "next-3000" | "convex-substrate";
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
|
||||
schema: "mnote.tree.3000_route_boundary",
|
||||
schemaVersion: 1,
|
||||
publicEntry: "3000",
|
||||
routes: [
|
||||
{
|
||||
id: "tree.commands",
|
||||
role: "next-thin-proxy",
|
||||
route: "/api/tree/commands",
|
||||
owner: "next-3000",
|
||||
description:
|
||||
"浏览器公开树命令入口,只负责认证、payload envelope、Rust command transport、artifact writer 调用与必要副作用调度。",
|
||||
},
|
||||
{
|
||||
id: "tree.stream",
|
||||
role: "next-thin-proxy",
|
||||
route: "/api/tree/stream",
|
||||
owner: "next-3000",
|
||||
description:
|
||||
"浏览器 SSE/polling 入口,只转发 bridge log/domain event cursor 与 Rust 产出的 streamDelta,缺少稳定 delta 时保守 resync。",
|
||||
},
|
||||
{
|
||||
id: "tree.shell.debug",
|
||||
role: "compat-pending",
|
||||
route: "/api/tree/shell",
|
||||
owner: "next-3000",
|
||||
description:
|
||||
"仅服务显式 debug/internal runtime 验证;3000 主路径使用 same-origin inline host,不应默认请求 mnote-web:3104。",
|
||||
},
|
||||
] satisfies TreeRouteBoundaryItem[],
|
||||
rustOwnedSemantics: [
|
||||
"tree.node.create",
|
||||
"tree.node.rename",
|
||||
"tree.subtree.move",
|
||||
"tree.node.archive",
|
||||
"tree.node.restore",
|
||||
"tree.node.purge",
|
||||
"tree.subtree.copy",
|
||||
"tree.node.embed",
|
||||
],
|
||||
nextThinProxyDuties: [
|
||||
"cookie/auth 读取与 Convex client 获取",
|
||||
"workspace bootstrap",
|
||||
"CommandEnvelope 构造与 Rust runtime transport",
|
||||
"Rust command result 回包整形",
|
||||
"Rust artifact writer 调用",
|
||||
"tree.subtree.move 的 sidebar snapshot preflight 数据采集",
|
||||
],
|
||||
browserSubstrateDuties: [
|
||||
"新页面 scaffold 文件创建",
|
||||
"复制页面后的 mindmap 文件复制",
|
||||
"页面嵌入前读取目标内容、源页面标题与 anchor block,作为 Rust Page Aggregate embed plan 的 preflight substrate",
|
||||
"文件字节读取、upload URL、cookie/auth 等浏览器入口能力",
|
||||
],
|
||||
compatPending: [] satisfies TreeRouteBoundaryItem[],
|
||||
forbiddenNextSemantics: [
|
||||
"树合法性判断",
|
||||
"树排序 canonical plan",
|
||||
"长期 streamDelta 主语义拼装",
|
||||
"tree shell renderer runtime",
|
||||
"tree.node.embed 的 pageReference block 结构与插入位置语义",
|
||||
],
|
||||
} as const;
|
||||
Reference in New Issue
Block a user