feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
+2
View File
@@ -13,6 +13,7 @@ import type * as _utils_auth from "../_utils/auth.js";
import type * as _utils_documentMoveOrder from "../_utils/documentMoveOrder.js";
import type * as _utils_documentRecord from "../_utils/documentRecord.js";
import type * as _utils_documentTree from "../_utils/documentTree.js";
import type * as _utils_documentVisibility from "../_utils/documentVisibility.js";
import type * as _utils_id from "../_utils/id.js";
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
import type * as _utils_lightrag from "../_utils/lightrag.js";
@@ -59,6 +60,7 @@ declare const fullApi: ApiFromModules<{
"_utils/documentMoveOrder": typeof _utils_documentMoveOrder;
"_utils/documentRecord": typeof _utils_documentRecord;
"_utils/documentTree": typeof _utils_documentTree;
"_utils/documentVisibility": typeof _utils_documentVisibility;
"_utils/id": typeof _utils_id;
"_utils/ingestJobs": typeof _utils_ingestJobs;
"_utils/lightrag": typeof _utils_lightrag;
+223 -23
View File
@@ -36,6 +36,34 @@ type CopyTreeDocument = {
deleted_at: string | null;
};
type RestoreLocationDocument = {
_id?: any;
id: string;
user_id: string;
workspace_id: string;
parent_id: string | null;
sort_order: number | null;
created_at?: string | null;
deleted_at?: string | null;
restore_parent_id?: string | null;
restore_sort_order?: number | null;
};
export type DocumentRestoreLocation = {
parentId: string | null;
sortOrder: number | null;
fallbackReason: "none" | "parent_missing_or_deleted";
};
export type DocumentRestoreOrderAssignment = {
id: string;
_id?: any;
parentId: string | null;
sortOrder: number;
previousSortOrder: number | null;
isRestored: boolean;
};
function normalizeTitle(title: string | null | undefined): string {
const safe = String(title ?? "").trim();
return safe.length > 0 ? safe : "无标题";
@@ -77,6 +105,111 @@ function extractBlocksFromContent(content: unknown): unknown[] {
return [];
}
export function buildDocumentTrashLocationPatch(doc: {
parent_id?: string | null;
sort_order?: number | null;
}) {
return {
restore_parent_id: doc.parent_id ?? null,
restore_sort_order: typeof doc.sort_order === "number" ? doc.sort_order : null,
};
}
function clampRestoreSortOrder(sortOrder: number | null | undefined, siblingCount: number): number | null {
if (typeof sortOrder !== "number" || !Number.isFinite(sortOrder)) {
return siblingCount;
}
return Math.max(0, Math.min(Math.floor(sortOrder), siblingCount));
}
export function resolveDocumentRestoreLocation(input: {
doc: RestoreLocationDocument;
ownedDocs: RestoreLocationDocument[];
restoringIds?: Set<string>;
}): DocumentRestoreLocation {
const requestedParentId = input.doc.restore_parent_id ?? input.doc.parent_id ?? null;
const restoringIds = input.restoringIds ?? new Set<string>();
const parentAlive =
requestedParentId == null ||
restoringIds.has(requestedParentId) ||
input.ownedDocs.some(
(candidate) =>
candidate.id === requestedParentId &&
candidate.user_id === input.doc.user_id &&
candidate.workspace_id === input.doc.workspace_id &&
candidate.deleted_at == null,
);
const parentId = parentAlive ? requestedParentId : null;
const siblingCount = input.ownedDocs.filter(
(candidate) =>
candidate.id !== input.doc.id &&
candidate.user_id === input.doc.user_id &&
candidate.workspace_id === input.doc.workspace_id &&
candidate.deleted_at == null &&
(candidate.parent_id ?? null) === parentId,
).length;
return {
parentId,
sortOrder: clampRestoreSortOrder(input.doc.restore_sort_order ?? input.doc.sort_order, siblingCount),
fallbackReason: parentAlive ? "none" : "parent_missing_or_deleted",
};
}
export function buildDocumentRestoreOrderAssignments(input: {
restoringDocs: RestoreLocationDocument[];
ownedDocs: RestoreLocationDocument[];
locationsById: Map<string, DocumentRestoreLocation>;
restoringIds?: Set<string>;
}): DocumentRestoreOrderAssignment[] {
const restoringIds = input.restoringIds ?? new Set(input.restoringDocs.map((doc) => doc.id));
const affectedParentIds = new Set<string | null>();
for (const location of input.locationsById.values()) {
affectedParentIds.add(location.parentId);
}
const assignments: DocumentRestoreOrderAssignment[] = [];
for (const parentId of affectedParentIds) {
const rows = [
...input.ownedDocs
.filter((candidate) => !restoringIds.has(candidate.id))
.filter((candidate) => candidate.deleted_at == null)
.filter((candidate) => (candidate.parent_id ?? null) === parentId)
.map((candidate) => ({
doc: candidate,
desiredSortOrder: typeof candidate.sort_order === "number" ? candidate.sort_order : Number.MAX_SAFE_INTEGER,
isRestored: false,
})),
...input.restoringDocs
.filter((candidate) => input.locationsById.get(candidate.id)?.parentId === parentId)
.map((candidate) => {
const location = input.locationsById.get(candidate.id);
return {
doc: candidate,
desiredSortOrder: typeof location?.sortOrder === "number" ? location.sortOrder : Number.MAX_SAFE_INTEGER,
isRestored: true,
};
}),
].sort((a, b) => {
if (a.desiredSortOrder !== b.desiredSortOrder) return a.desiredSortOrder - b.desiredSortOrder;
if (a.isRestored !== b.isRestored) return a.isRestored ? -1 : 1;
return String(a.doc.created_at ?? a.doc.id).localeCompare(String(b.doc.created_at ?? b.doc.id));
});
rows.forEach((row, index) => {
assignments.push({
id: row.doc.id,
_id: row.doc._id,
parentId,
sortOrder: index,
previousSortOrder: typeof row.doc.sort_order === "number" ? row.doc.sort_order : null,
isRestored: row.isRestored,
});
});
}
return assignments;
}
function composeContentWithBlocks(content: unknown, blocks: unknown[]): unknown {
if (Array.isArray(content)) {
return blocks as unknown[];
@@ -682,13 +815,13 @@ async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentI
}
export const getMeta = query({
args: { id: v.string() },
args: { id: v.string(), includeDeleted: v.optional(v.boolean()) },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
if (doc.deleted_at != null && args.includeDeleted !== true) return null;
try {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
} catch {
@@ -746,7 +879,7 @@ export const getMeta = query({
export const getPermissionForUser = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
@@ -774,7 +907,7 @@ export const getPermissionForUser = query({
export const getMetaForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
@@ -805,7 +938,7 @@ export const getContent = query({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.deleted_at != null) return null;
try {
@@ -847,7 +980,7 @@ export const getContent = query({
export const getContentForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return {
@@ -1299,7 +1432,7 @@ export const updateContent = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
@@ -1419,7 +1552,7 @@ export const move = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
@@ -1581,7 +1714,7 @@ export const softDelete = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
@@ -1597,7 +1730,12 @@ export const softDelete = mutation({
let moved = 0;
for (const item of subtree) {
if (item.deleted_at != null) continue;
await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
await ctx.db.patch(item._id, {
deleted_at: ts,
deleted_by: userId,
...buildDocumentTrashLocationPatch(item),
updated_at: ts,
});
// 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。
// 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
@@ -1613,42 +1751,98 @@ export const restore = mutation({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await getCanonicalDocumentByBusinessId(ctx, args.id);
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
const restoreDeletedAt = doc.deleted_at;
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = restoreDeletedAt != null ? collectSubtree(owned, doc.id) : [doc];
const restoringIds = new Set(
subtree
.filter((item) => item.id === doc.id || item.deleted_at === restoreDeletedAt)
.map((item) => item.id)
.filter((id): id is string => typeof id === "string"),
);
const restoringDocs = subtree.filter((item) => restoringIds.has(item.id));
const locationsById = new Map<string, DocumentRestoreLocation>();
for (const item of restoringDocs) {
locationsById.set(
item.id,
resolveDocumentRestoreLocation({
doc: item,
ownedDocs: owned,
restoringIds,
}),
);
}
const orderAssignments = buildDocumentRestoreOrderAssignments({
restoringDocs,
ownedDocs: owned,
locationsById,
restoringIds,
});
const assignmentById = new Map(orderAssignments.map((assignment) => [assignment.id, assignment]));
for (const assignment of orderAssignments) {
if (assignment.isRestored) continue;
if (assignment.previousSortOrder === assignment.sortOrder) continue;
await ctx.db.patch(assignment._id, { sort_order: assignment.sortOrder });
}
const restoreLocation = locationsById.get(doc.id) ?? {
parentId: null,
sortOrder: null,
fallbackReason: "parent_missing_or_deleted" as const,
};
const restoreAssignment = assignmentById.get(doc.id);
const restoredParentId = restoreAssignment?.parentId ?? restoreLocation.parentId;
const restoredSortOrder = restoreAssignment?.sortOrder ?? restoreLocation.sortOrder;
await ctx.db.patch(doc._id, {
deleted_at: null,
deleted_by: null,
parent_id: null,
parent_id: restoredParentId,
sort_order: restoredSortOrder,
restore_parent_id: null,
restore_sort_order: null,
access_scope: "private",
updated_at: ts,
});
// 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。
if (restoreDeletedAt != null) {
const allInWorkspace = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
.collect();
const owned = allInWorkspace.filter((d) => d.user_id === userId);
const subtree = collectSubtree(owned, doc.id);
for (const item of subtree) {
if (item.id === doc.id) continue;
if (item.deleted_at !== restoreDeletedAt) continue;
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
const childRestoreLocation = locationsById.get(item.id);
const childRestoreAssignment = assignmentById.get(item.id);
await ctx.db.patch(item._id, {
deleted_at: null,
deleted_by: null,
parent_id: childRestoreAssignment?.parentId ?? childRestoreLocation?.parentId ?? null,
sort_order: childRestoreAssignment?.sortOrder ?? childRestoreLocation?.sortOrder ?? null,
restore_parent_id: null,
restore_sort_order: null,
updated_at: ts,
});
}
}
return {
ok: true,
updated_at: ts,
restore_location: {
parent_id: restoredParentId,
sort_order: restoredSortOrder,
fallback_reason: restoreLocation.fallbackReason,
},
document: toDocumentDeltaRecord({
...doc,
deleted_at: null,
deleted_by: null,
parent_id: null,
parent_id: restoredParentId,
sort_order: restoredSortOrder,
access_scope: "private",
updated_at: ts,
}),
@@ -1685,7 +1879,13 @@ export const purge = mutation({
});
export const emptyTrashByWorkspace = mutation({
args: { workspaceId: v.string() },
args: {
workspaceId: v.string(),
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
+3
View File
@@ -81,6 +81,9 @@ export default defineSchema({
// 软删除(阶段 4 先不实现垃圾桶逻辑,但字段先留好,便于后续迁移)。
deleted_at: v.union(v.string(), v.null()),
deleted_by: v.union(v.string(), v.null()),
// 垃圾箱恢复位置快照:删除时记录原父节点与排序,恢复时尽量回放。
restore_parent_id: v.optional(v.union(v.string(), v.null())),
restore_sort_order: v.optional(v.union(v.number(), v.null())),
// 兼容旧逻辑:Supabase documents.mindmap_data。
mindmap_data: v.optional(v.any()),