- 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
219 lines
7.4 KiB
TypeScript
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;
|
|
}
|