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:
lix-2026
2026-04-26 19:35:52 +08:00
parent 338bb2e20f
commit e564dfde02
93 changed files with 17492 additions and 1856 deletions
@@ -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 当前排序状态不一致");
}
}
+72 -12
View File
@@ -4,6 +4,10 @@ import { v } from "convex/values";
import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time";
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
import {
assertDocumentMoveOrderPlanMatches,
buildDocumentMoveOrderPlanFromDocuments,
} from "./_utils/documentMoveOrder";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
import { extractTextFromDocumentContent } from "./_utils/text";
import {
@@ -216,6 +220,21 @@ async function createDocumentRecord(
};
}
function toDocumentDeltaRecord(doc: any) {
return {
id: doc.id,
workspace_id: doc.workspace_id,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
sort_order: doc.sort_order ?? null,
is_starred: doc.is_starred ?? false,
access_scope: (doc.access_scope ?? "private") as "private" | "shared" | "public",
is_template: Boolean(doc.is_template),
created_at: doc.created_at ?? nowIso(),
updated_at: doc.updated_at ?? null,
};
}
async function updateDocumentContentRecord(
ctx: any,
args: {
@@ -1198,7 +1217,7 @@ export const create = mutation({
deleted_by: null,
});
return {
const document = {
id: args.id,
title,
parent_id: args.parentId,
@@ -1210,6 +1229,10 @@ export const create = mutation({
access_scope: args.accessScope,
is_template: false,
};
return {
...document,
document: toDocumentDeltaRecord(document),
};
},
});
@@ -1376,6 +1399,7 @@ export const move = mutation({
id: v.string(),
parentId: v.union(v.string(), v.null()),
sortOrder: v.number(),
normalizedMove: v.optional(v.any()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -1386,6 +1410,14 @@ export const move = mutation({
if (doc.user_id !== userId) throw new Error("无权限");
const toParentId = args.parentId;
const workspaceDocs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
.filter((row) => row.user_id === userId)
.filter((row) => row.deleted_at == null);
if (toParentId === doc.id) {
throw new Error("不能把页面移动到自身下面");
}
@@ -1398,19 +1430,24 @@ export const move = mutation({
throw new Error("暂不支持跨工作空间移动页面");
}
const workspaceDocs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
.filter((row) => row.user_id === userId)
.filter((row) => row.deleted_at == null);
const parentById = buildParentById(canonicalWorkspaceDocs);
if (isAncestorOf(doc.id, toParentId, parentById)) {
throw new Error("不能把页面移动到自己的后代下面");
}
}
if (args.normalizedMove != null) {
assertDocumentMoveOrderPlanMatches(
args.normalizedMove,
buildDocumentMoveOrderPlanFromDocuments({
documents: canonicalWorkspaceDocs,
documentId: doc.id,
parentId: toParentId,
sortOrder: args.sortOrder,
}),
);
}
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
@@ -1565,7 +1602,18 @@ export const restore = mutation({
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
}
}
return { ok: true };
return {
ok: true,
updated_at: ts,
document: toDocumentDeltaRecord({
...doc,
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
updated_at: ts,
}),
};
},
});
@@ -1801,8 +1849,10 @@ export const duplicate = mutation({
title,
parent_id: source.parent_id ?? null,
sort_order: sortOrder,
is_starred: false,
workspace_id: source.workspace_id,
access_scope: source.access_scope,
is_template: false,
created_at: ts,
updated_at: ts,
};
@@ -1921,7 +1971,12 @@ export const copyTree = mutation({
throw new Error("没有可复制的页面");
}
const insertedDocs: Array<{ oldId: string; newId: string; title: string }> = [];
const insertedDocs: Array<{
oldId: string;
newId: string;
title: string;
document: ReturnType<typeof toDocumentDeltaRecord>;
}> = [];
for (const item of copyQueue) {
const newId = newIdByOldId.get(item.old.id)!;
@@ -1935,7 +1990,7 @@ export const copyTree = mutation({
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
await createDocumentRecord(ctx, {
const created = await createDocumentRecord(ctx, {
id: newId,
workspaceId,
parentId,
@@ -1945,7 +2000,12 @@ export const copyTree = mutation({
});
await copyMindmapsForDocument(ctx, item.old.id, newId);
insertedDocs.push({ oldId: item.old.id, newId, title: newTitle });
insertedDocs.push({
oldId: item.old.id,
newId,
title: newTitle,
document: toDocumentDeltaRecord(created),
});
}
return {
+325 -1
View File
@@ -72,6 +72,201 @@ function shouldExtractAttachmentText(args: {
return false;
}
function splitExtension(fileName: string): { base: string; ext: string } {
const safe = fileName.trim();
const lastDot = safe.lastIndexOf(".");
if (lastDot <= 0 || lastDot === safe.length - 1) {
return { base: safe, ext: "" };
}
return { base: safe.slice(0, lastDot), ext: safe.slice(lastDot) };
}
function makeUniqueFileName(fileName: string, existing: Set<string>): string {
const safe = (fileName.trim() || "附件").replace(/[\\/]/g, "_");
if (!existing.has(safe)) {
existing.add(safe);
return safe;
}
const { base, ext } = splitExtension(safe);
const first = `${base} 副本${ext}`;
if (!existing.has(first)) {
existing.add(first);
return first;
}
for (let i = 2; i < 1000; i += 1) {
const candidate = `${base} 副本 ${i}${ext}`;
if (!existing.has(candidate)) {
existing.add(candidate);
return candidate;
}
}
const fallback = `${base} 副本 ${Date.now()}${ext}`;
existing.add(fallback);
return fallback;
}
function uniqueStrings(values: string[]): string[] {
const out: string[] = [];
for (const value of values) {
const normalized = String(value ?? "").trim();
if (!normalized || out.includes(normalized)) {
continue;
}
out.push(normalized);
}
return out;
}
function validateResourceTransferPlan(args: {
action: "copy" | "move";
assetIds: string[];
targetDocumentId: string;
targetSubPath?: string | null;
resourceTransferPlan?: any;
}) {
const plan = args.resourceTransferPlan;
if (!plan) {
return;
}
if (plan.action !== args.action) {
throw new Error("资源操作计划不一致");
}
if (String(plan.targetDocumentId ?? "").trim() !== args.targetDocumentId) {
throw new Error("资源目标页面计划不一致");
}
const plannedSubPath = String(plan.targetSubPath ?? "").trim();
const actualSubPath = String(args.targetSubPath ?? "").trim();
if (plannedSubPath !== actualSubPath) {
throw new Error("资源目标子路径计划不一致");
}
const plannedAssetIds = Array.isArray(plan.assetIds)
? uniqueStrings(plan.assetIds.map((value: unknown) => String(value ?? "")))
: [];
if (plannedAssetIds.length !== args.assetIds.length) {
throw new Error("资源列表计划不一致");
}
for (let i = 0; i < args.assetIds.length; i += 1) {
if (plannedAssetIds[i] !== args.assetIds[i]) {
throw new Error("资源列表计划不一致");
}
}
}
function validateResourceUploadPlan(args: {
asset: {
id: string;
workspace_id: string;
document_id: string;
asset_type: string;
file_name?: string | null;
file_size?: number | null;
mime_type?: string | null;
};
targetSubPath?: string | null;
resourceUploadPlan?: any;
}) {
const plan = args.resourceUploadPlan;
if (!plan || typeof plan !== "object") {
return;
}
if (plan.action !== "upload") {
throw new Error("Rust resource upload plan action 不一致");
}
if (String(plan.assetId ?? "").trim() !== args.asset.id) {
throw new Error("Rust resource upload plan assetId 不一致");
}
if (String(plan.workspaceId ?? "").trim() !== args.asset.workspace_id) {
throw new Error("Rust resource upload plan workspaceId 不一致");
}
if (String(plan.targetDocumentId ?? "").trim() !== args.asset.document_id) {
throw new Error("Rust resource upload plan targetDocumentId 不一致");
}
const plannedSubPath = String(plan.targetSubPath ?? "").trim();
const actualSubPath = String(args.targetSubPath ?? "").trim();
if (plannedSubPath !== actualSubPath) {
throw new Error("Rust resource upload plan targetSubPath 不一致");
}
if (String(plan.assetType ?? "").trim() !== args.asset.asset_type) {
throw new Error("Rust resource upload plan assetType 不一致");
}
const plannedName = String(plan.fileName ?? "").trim();
const actualName = String(args.asset.file_name ?? "").trim();
if (plannedName !== actualName) {
throw new Error("Rust resource upload plan fileName 不一致");
}
if (typeof plan.fileSize === "number" && plan.fileSize !== args.asset.file_size) {
throw new Error("Rust resource upload plan fileSize 不一致");
}
const plannedMime = String(plan.mimeType ?? "").trim();
const actualMime = String(args.asset.mime_type ?? "").trim();
if (plannedMime !== actualMime) {
throw new Error("Rust resource upload plan mimeType 不一致");
}
}
async function loadTransferAssets(ctx: MutationCtx, userId: string, assetIds: string[]) {
const assets: any[] = [];
for (const assetId of assetIds) {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", assetId))
.first();
if (!row || row.deleted_at || row.purged_at) {
continue;
}
await assertWorkspaceMember(ctx, userId, row.workspace_id);
assets.push(row);
}
return assets;
}
async function loadExistingNames(ctx: MutationCtx, documentId: string) {
const existingRows = await ctx.db
.query("media_assets")
.withIndex("by_document", (q) => q.eq("document_id", documentId))
.collect();
return new Set<string>(
existingRows.map((row) => String(row.file_name ?? "")).filter((name) => name.length > 0),
);
}
async function resolveTransferTarget(ctx: MutationCtx, userId: string, targetDocumentId: string) {
const targetDoc = await getCanonicalDocumentByBusinessId<any>(ctx, targetDocumentId);
if (!targetDoc) {
throw new Error("目标页面不存在");
}
await assertWorkspaceMember(ctx, userId, targetDoc.workspace_id);
return targetDoc;
}
function buildTransferredAssetResult(row: any) {
return {
id: row.id,
workspace_id: row.workspace_id,
document_id: row.document_id,
asset_type: row.asset_type,
file_url: row.file_url ?? null,
thumbnail_url: row.thumbnail_url ?? row.file_url ?? null,
storage_id: row.storage_id ?? null,
bucket: row.bucket ?? null,
storage_path: row.storage_path ?? null,
file_name: row.file_name ?? null,
file_size: row.file_size ?? null,
mime_type: row.mime_type ?? null,
ocr_text: row.ocr_text ?? null,
ocr_status: row.ocr_status ?? null,
ocr_payload: row.ocr_payload,
ocr_strategy: row.ocr_strategy ?? null,
deleted_at: row.deleted_at ?? null,
deleted_by: row.deleted_by ?? null,
purged_at: row.purged_at ?? null,
signed_url: row.signed_url ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export const getById = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
@@ -290,6 +485,8 @@ export const createWithStorage = mutation({
args: {
userId: v.string(),
storageId: v.id("_storage"),
targetSubPath: v.optional(v.union(v.string(), v.null())),
resourceUploadPlan: v.optional(v.any()),
asset: v.object({
id: v.string(),
workspace_id: v.string(),
@@ -302,6 +499,11 @@ export const createWithStorage = mutation({
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
validateResourceUploadPlan({
asset: args.asset,
targetSubPath: args.targetSubPath,
resourceUploadPlan: args.resourceUploadPlan,
});
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.asset.document_id);
@@ -324,7 +526,7 @@ export const createWithStorage = mutation({
thumbnail_url: url,
storage_id: args.storageId,
bucket: null,
storage_path: null,
storage_path: args.targetSubPath ? `${args.targetSubPath}/${args.asset.file_name ?? args.asset.id}` : null,
file_name: args.asset.file_name,
file_size: args.asset.file_size,
mime_type: args.asset.mime_type,
@@ -360,6 +562,128 @@ export const createWithStorage = mutation({
},
});
export const batchCopy = mutation({
args: {
userId: v.string(),
assetIds: v.array(v.string()),
targetDocumentId: v.string(),
targetSubPath: v.optional(v.union(v.string(), v.null())),
resourceTransferPlan: v.optional(v.any()),
},
handler: async (ctx, args) => {
const assetIds = uniqueStrings(args.assetIds);
if (assetIds.length === 0) {
throw new Error("缺少附件");
}
const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId);
validateResourceTransferPlan({
action: "copy",
assetIds,
targetDocumentId: args.targetDocumentId,
targetSubPath: args.targetSubPath,
resourceTransferPlan: args.resourceTransferPlan,
});
const assets = await loadTransferAssets(ctx, args.userId, assetIds);
const existingNames = await loadExistingNames(ctx, args.targetDocumentId);
const items: any[] = [];
const ts = nowIso();
for (const asset of assets) {
const storageId = (asset.storage_id as any) ?? null;
if (!storageId) {
continue;
}
const id =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames);
const row = {
id,
workspace_id: String(targetDoc.workspace_id),
document_id: String(args.targetDocumentId),
asset_type: String(asset.asset_type ?? "file"),
file_url: asset.file_url ?? null,
thumbnail_url: asset.thumbnail_url ?? asset.file_url ?? null,
storage_id: storageId,
bucket: asset.bucket ?? null,
storage_path: asset.storage_path ?? null,
file_name: fileName,
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
mime_type: (asset.mime_type ?? null) as any,
ocr_text: null,
ocr_status: shouldExtractAttachmentText({
assetType: asset.asset_type,
mimeType: asset.mime_type,
fileName,
})
? "queued"
: null,
ocr_payload: undefined,
ocr_strategy: null,
deleted_at: null,
deleted_by: null,
purged_at: null,
created_by: args.userId,
created_at: ts,
updated_at: ts,
};
await ctx.db.insert("media_assets", row);
if (row.ocr_status === "queued") {
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: id, debounceMs: 800 });
}
items.push(buildTransferredAssetResult(row));
}
return { items };
},
});
export const batchMove = mutation({
args: {
userId: v.string(),
assetIds: v.array(v.string()),
targetDocumentId: v.string(),
targetSubPath: v.optional(v.union(v.string(), v.null())),
resourceTransferPlan: v.optional(v.any()),
},
handler: async (ctx, args) => {
const assetIds = uniqueStrings(args.assetIds);
if (assetIds.length === 0) {
throw new Error("缺少附件");
}
const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId);
validateResourceTransferPlan({
action: "move",
assetIds,
targetDocumentId: args.targetDocumentId,
targetSubPath: args.targetSubPath,
resourceTransferPlan: args.resourceTransferPlan,
});
const assets = await loadTransferAssets(ctx, args.userId, assetIds);
const existingNames = await loadExistingNames(ctx, args.targetDocumentId);
const items: any[] = [];
for (const asset of assets) {
const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames);
const patch = {
workspace_id: String(targetDoc.workspace_id),
document_id: String(args.targetDocumentId),
file_name: fileName,
updated_at: nowIso(),
};
await ctx.db.patch(asset._id, patch);
items.push(buildTransferredAssetResult({ ...asset, ...patch }));
}
return { items };
},
});
export const patchById = mutation({
args: {
userId: v.string(),