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(); 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; 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; 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 当前排序状态不一致"); } }