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,164 @@
|
||||
export type DocumentMoveOrderDocument = {
|
||||
id: string;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type DocumentMoveOrderPatch = {
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export type DocumentMoveOrderPlan = {
|
||||
documentId: string;
|
||||
fromParentId: string | null;
|
||||
toParentId: string | null;
|
||||
requestedSortOrder: number;
|
||||
normalizedSortOrder: number;
|
||||
patches: DocumentMoveOrderPatch[];
|
||||
};
|
||||
|
||||
function normalizeParentId(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number {
|
||||
const orderA = normalizeSortOrder(a.sort_order);
|
||||
const orderB = normalizeSortOrder(b.sort_order);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
const createdA = String(a.created_at ?? "");
|
||||
const createdB = String(b.created_at ?? "");
|
||||
if (createdA !== createdB) return createdA.localeCompare(createdB);
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function clampMoveIndex(raw: number, max: number): number {
|
||||
const value = Number.isFinite(raw) ? Math.floor(raw) : 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendOrderPatches(
|
||||
patches: DocumentMoveOrderPatch[],
|
||||
ordered: DocumentMoveOrderDocument[],
|
||||
parentId: string | null,
|
||||
movedDocumentId: string,
|
||||
) {
|
||||
ordered.forEach((document, index) => {
|
||||
const moved = document.id === movedDocumentId;
|
||||
if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
patches.push({
|
||||
documentId: document.id,
|
||||
parentId,
|
||||
sortOrder: index,
|
||||
moved,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDocumentMoveOrderPlanFromDocuments(input: {
|
||||
documents: readonly DocumentMoveOrderDocument[];
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
}): DocumentMoveOrderPlan {
|
||||
const source = input.documents.find((document) => document.id === input.documentId);
|
||||
if (!source) {
|
||||
throw new Error("源页面不存在或无权限");
|
||||
}
|
||||
|
||||
const fromParentId = normalizeParentId(source.parent_id);
|
||||
const toParentId = normalizeParentId(input.parentId);
|
||||
const siblingsByParent = new Map<string | null, DocumentMoveOrderDocument[]>();
|
||||
input.documents.forEach((document) => {
|
||||
const parentId = normalizeParentId(document.parent_id);
|
||||
const bucket = siblingsByParent.get(parentId);
|
||||
if (bucket) bucket.push(document);
|
||||
else siblingsByParent.set(parentId, [document]);
|
||||
});
|
||||
siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder));
|
||||
|
||||
const patches: DocumentMoveOrderPatch[] = [];
|
||||
let normalizedSortOrder = 0;
|
||||
|
||||
if (fromParentId === toParentId) {
|
||||
const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length);
|
||||
siblings.splice(normalizedSortOrder, 0, source);
|
||||
appendOrderPatches(patches, siblings, toParentId, input.documentId);
|
||||
} else {
|
||||
const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId);
|
||||
|
||||
const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length);
|
||||
newSiblings.splice(normalizedSortOrder, 0, source);
|
||||
appendOrderPatches(patches, newSiblings, toParentId, input.documentId);
|
||||
}
|
||||
|
||||
return {
|
||||
documentId: input.documentId,
|
||||
fromParentId,
|
||||
toParentId,
|
||||
requestedSortOrder: input.sortOrder,
|
||||
normalizedSortOrder,
|
||||
patches,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Partial<DocumentMoveOrderPlan>;
|
||||
if (
|
||||
typeof record.documentId !== "string" ||
|
||||
typeof record.requestedSortOrder !== "number" ||
|
||||
typeof record.normalizedSortOrder !== "number" ||
|
||||
!Array.isArray(record.patches)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
documentId: record.documentId,
|
||||
fromParentId: normalizeParentId(record.fromParentId),
|
||||
toParentId: normalizeParentId(record.toParentId),
|
||||
requestedSortOrder: record.requestedSortOrder,
|
||||
normalizedSortOrder: record.normalizedSortOrder,
|
||||
patches: record.patches.map((patch) => {
|
||||
const item = patch as Partial<DocumentMoveOrderPatch>;
|
||||
return {
|
||||
documentId: String(item.documentId ?? ""),
|
||||
parentId: normalizeParentId(item.parentId),
|
||||
sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1,
|
||||
moved: Boolean(item.moved),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan) {
|
||||
const normalizedExpected = normalizePlan(expected);
|
||||
if (!normalizedExpected) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user