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:
+2
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user