Files
mnote/wolai-frontend/convex/_utils/documentMoveOrder.ts
T
lix-2026 4ab36a9386 feat(tree): complete rust family runtime checklist
- add tree shell runtime artifact contracts and page/filetree/picker runtime reducers
- sink tree.subtree.move write operation through Rust and formalize command event plans
- harden file tree search projection contract and route thin-proxy boundaries
- record completed harness tasks and move design docs into process/done
2026-04-27 10:27:15 +08:00

219 lines
7.4 KiB
TypeScript

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[];
};
export type DocumentMoveWriteOperation = DocumentMoveOrderPlan & {
family: "tree";
schema: "mnote.tree.write_operation";
schemaVersion: 1;
operation: "tree.subtree.move.write";
workspaceId: string;
};
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,
};
}
export function normalizeDocumentMoveOrderPlan(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 normalizeDocumentMoveWriteOperation(value: unknown): DocumentMoveWriteOperation | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Partial<DocumentMoveWriteOperation>;
if (
record.family !== "tree" ||
record.schema !== "mnote.tree.write_operation" ||
record.schemaVersion !== 1 ||
record.operation !== "tree.subtree.move.write" ||
typeof record.workspaceId !== "string" ||
!record.workspaceId.trim()
) {
return null;
}
const normalizedPlan = normalizeDocumentMoveOrderPlan(value);
if (!normalizedPlan) {
return null;
}
return {
family: "tree",
schema: "mnote.tree.write_operation",
schemaVersion: 1,
operation: "tree.subtree.move.write",
workspaceId: record.workspaceId.trim(),
...normalizedPlan,
};
}
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveOrderPlan(expected);
if (!normalizedExpected) {
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
}
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
}
return normalizedExpected;
}
export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan {
const normalizedExpected = normalizeDocumentMoveWriteOperation(expected);
if (!normalizedExpected) {
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
}
const { family: _family, schema: _schema, schemaVersion: _schemaVersion, operation: _operation, workspaceId: _workspaceId, ...expectedPlan } =
normalizedExpected;
if (JSON.stringify(expectedPlan) !== JSON.stringify(actual)) {
throw new Error("Rust move write operation 与 Convex 当前排序状态不一致");
}
return expectedPlan;
}