0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
+2
View File
@@ -16,6 +16,7 @@ import type * as _utils_lightrag from "../_utils/lightrag.js";
import type * as _utils_text from "../_utils/text.js";
import type * as _utils_time from "../_utils/time.js";
import type * as auth from "../auth.js";
import type * as comments from "../comments.js";
import type * as documentGroupShares from "../documentGroupShares.js";
import type * as documentShares from "../documentShares.js";
import type * as documentStars from "../documentStars.js";
@@ -49,6 +50,7 @@ declare const fullApi: ApiFromModules<{
"_utils/text": typeof _utils_text;
"_utils/time": typeof _utils_time;
auth: typeof auth;
comments: typeof comments;
documentGroupShares: typeof documentGroupShares;
documentShares: typeof documentShares;
documentStars: typeof documentStars;
+14 -3
View File
@@ -3,10 +3,21 @@ type IngestTextArgs = {
text: string;
};
export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackId: string | null }> {
type IngestTextResult = {
trackId: string | null;
skipped?: boolean;
reason?: string;
};
export function isLightRagEnabled(): boolean {
return Boolean((process.env.LIGHTRAG_URL || "").trim());
}
export async function lightragIngestText(args: IngestTextArgs): Promise<IngestTextResult> {
const baseUrl = (process.env.LIGHTRAG_URL || "").trim().replace(/\/+$/, "");
if (!baseUrl) {
throw new Error("缺少环境变量:LIGHTRAG_URL");
// 说明:LightRAG 未配置时不应让任务系统持续报错;直接降级为“跳过入库”。
return { trackId: null, skipped: true, reason: "missing_env_LIGHTRAG_URL" };
}
const fileSource = String(args.fileSource ?? "").trim();
@@ -16,7 +27,7 @@ export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackI
const text = String(args.text ?? "");
if (!text.trim()) {
return { trackId: null };
return { trackId: null, skipped: true, reason: "empty_text" };
}
const apiKey = (process.env.LIGHTRAG_API_KEY || "").trim();
+478
View File
@@ -0,0 +1,478 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new Error("未登录");
}
return userId;
}
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const member = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.first();
if (!member) {
throw new Error("无权限");
}
return member;
}
type SharePermission = "read" | "edit";
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const direct = await ctx.db
.query("document_shares")
.withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId))
.first();
if (direct) {
return direct.permission as SharePermission;
}
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = await ctx.db
.query("document_shares")
.withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId))
.first();
if (parentShare && parentShare.include_descendants) {
return parentShare.permission as SharePermission;
}
const parent = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
.first();
parentId = parent?.parent_id ?? null;
}
return null;
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const memberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId))
.collect();
if (!memberships.length) return null;
const groupIds = new Set(memberships.map((m: any) => String(m.group_id)));
const direct = await ctx.db
.query("document_group_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", doc.id))
.collect();
for (const row of direct) {
if (groupIds.has(String(row.group_id))) {
return "read";
}
}
// 说明:群组共享的“包含子页面”逻辑在 documents.ts 内已实现;这里做一个最小的祖先扫描即可。
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShares = await ctx.db
.query("document_group_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", parentId))
.collect();
for (const row of parentShares) {
if (row.include_descendants && groupIds.has(String(row.group_id))) {
return "read";
}
}
const parent = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
.first();
parentId = parent?.parent_id ?? null;
}
return null;
}
async function requireCanViewDocument(ctx: any, doc: any, userId: string) {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id === userId) return;
if (doc.access_scope === "public") return;
const perm =
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) {
throw new Error("无权限");
}
}
async function requireCanModerateDocument(ctx: any, doc: any, userId: string) {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id === userId) return;
if (doc.access_scope === "public") throw new Error("无权限");
const perm =
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (perm !== "edit") {
throw new Error("无权限");
}
}
const pickUserLite = (user: any) => {
if (!user) return null;
return {
id: String(user._id),
name: (user.name as string | undefined) ?? null,
image: (user.image as string | undefined) ?? null,
};
};
export const listThreadsByDocument = query({
args: {
documentId: v.string(),
includeResolved: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", args.documentId))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireCanViewDocument(ctx, doc, userId);
const threads = await ctx.db
.query("comment_threads")
.withIndex("by_document", (q: any) => q.eq("document_id", args.documentId))
.collect();
const includeResolved = Boolean(args.includeResolved);
const filtered = threads.filter((t: any) => includeResolved || t.resolved_at == null);
filtered.sort((a: any, b: any) => String(b.last_activity_at).localeCompare(String(a.last_activity_at)));
const userIds = new Set<string>();
filtered.forEach((t: any) => {
if (t.created_by) userIds.add(String(t.created_by));
if (t.last_comment_by) userIds.add(String(t.last_comment_by));
if (t.resolved_by) userIds.add(String(t.resolved_by));
});
const userById = new Map<string, any>();
for (const uid of userIds) {
const u = await ctx.db.get(uid as any);
if (u) userById.set(uid, u);
}
return filtered.map((t: any) => ({
id: t.id,
workspaceId: t.workspace_id,
documentId: t.document_id,
blockId: t.block_id ?? null,
resolvedAt: t.resolved_at ?? null,
resolvedBy: pickUserLite(userById.get(String(t.resolved_by ?? ""))),
commentCount: Number(t.comment_count ?? 0),
lastActivityAt: t.last_activity_at,
lastCommentPreview: t.last_comment_preview ?? null,
lastCommentBy: pickUserLite(userById.get(String(t.last_comment_by ?? ""))),
createdAt: t.created_at,
createdBy: pickUserLite(userById.get(String(t.created_by ?? ""))),
}));
},
});
export const listMessagesByThread = query({
args: { threadId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const thread = await ctx.db
.query("comment_threads")
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
.first();
if (!thread) throw new Error("评论线程不存在");
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireCanViewDocument(ctx, doc, userId);
const messages = await ctx.db
.query("comment_messages")
.withIndex("by_thread", (q: any) => q.eq("thread_id", args.threadId))
.collect();
messages.sort((a: any, b: any) => String(a.created_at).localeCompare(String(b.created_at)));
const userIds = new Set<string>();
messages.forEach((m: any) => {
if (m.created_by) userIds.add(String(m.created_by));
if (m.deleted_by) userIds.add(String(m.deleted_by));
});
const userById = new Map<string, any>();
for (const uid of userIds) {
const u = await ctx.db.get(uid as any);
if (u) userById.set(uid, u);
}
return messages.map((m: any) => ({
id: m.id,
threadId: m.thread_id,
documentId: m.document_id,
blockId: m.block_id ?? null,
parentId: m.parent_id ?? null,
body: m.deleted_at ? "" : m.body,
deletedAt: m.deleted_at ?? null,
createdAt: m.created_at,
createdBy: pickUserLite(userById.get(String(m.created_by ?? ""))),
}));
},
});
export const createThread = mutation({
args: {
id: v.string(),
documentId: v.string(),
workspaceId: v.string(),
blockId: v.optional(v.union(v.string(), v.null())),
messageId: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", args.documentId))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireCanViewDocument(ctx, doc, userId);
if (String(doc.workspace_id) !== String(args.workspaceId)) {
throw new Error("workspaceId 不匹配");
}
const body = args.body.trim();
if (!body) throw new Error("评论内容不能为空");
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
const ts = nowIso();
await ctx.db.insert("comment_threads", {
id: args.id,
workspace_id: args.workspaceId,
document_id: args.documentId,
block_id: args.blockId ?? null,
resolved_at: null,
resolved_by: null,
comment_count: 1,
last_activity_at: ts,
last_comment_preview: body.slice(0, 160),
last_comment_by: userId,
created_by: userId,
created_at: ts,
});
await ctx.db.insert("comment_messages", {
id: args.messageId,
thread_id: args.id,
workspace_id: args.workspaceId,
document_id: args.documentId,
block_id: args.blockId ?? null,
parent_id: null,
body,
created_by: userId,
created_at: ts,
deleted_at: null,
deleted_by: null,
});
return { ok: true };
},
});
export const reply = mutation({
args: {
messageId: v.string(),
threadId: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const thread = await ctx.db
.query("comment_threads")
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
.first();
if (!thread) throw new Error("评论线程不存在");
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireCanViewDocument(ctx, doc, userId);
const body = args.body.trim();
if (!body) throw new Error("评论内容不能为空");
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
const ts = nowIso();
await ctx.db.insert("comment_messages", {
id: args.messageId,
thread_id: args.threadId,
workspace_id: thread.workspace_id,
document_id: thread.document_id,
block_id: thread.block_id ?? null,
parent_id: null,
body,
created_by: userId,
created_at: ts,
deleted_at: null,
deleted_by: null,
});
await ctx.db.patch(thread._id, {
comment_count: Number(thread.comment_count ?? 0) + 1,
last_activity_at: ts,
last_comment_preview: body.slice(0, 160),
last_comment_by: userId,
});
return { ok: true };
},
});
export const setResolved = mutation({
args: { threadId: v.string(), resolved: v.boolean() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const thread = await ctx.db
.query("comment_threads")
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
.first();
if (!thread) throw new Error("评论线程不存在");
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
// 说明:解决/取消解决属于“管理”行为,要求可编辑权限,避免只读用户随意改状态。
await requireCanModerateDocument(ctx, doc, userId);
const ts = nowIso();
await ctx.db.patch(thread._id, {
resolved_at: args.resolved ? ts : null,
resolved_by: args.resolved ? userId : null,
last_activity_at: ts,
});
return { ok: true };
},
});
export const editMessage = mutation({
args: { messageId: v.string(), body: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const message = await ctx.db
.query("comment_messages")
.withIndex("by_message_id", (q: any) => q.eq("id", args.messageId))
.first();
if (!message) throw new Error("评论不存在");
if (message.deleted_at != null) throw new Error("该评论已删除");
if (String(message.created_by) !== String(userId)) throw new Error("无权限");
const thread = await ctx.db
.query("comment_threads")
.withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id))
.first();
if (!thread) throw new Error("评论线程不存在");
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", message.document_id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireCanViewDocument(ctx, doc, userId);
const body = args.body.trim();
if (!body) throw new Error("评论内容不能为空");
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
const ts = nowIso();
await ctx.db.patch(message._id, { body });
const messages = await ctx.db
.query("comment_messages")
.withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id))
.collect();
messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at)));
const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null;
await ctx.db.patch(thread._id, {
last_activity_at: ts,
...(latestAlive && String(latestAlive.id) === String(message.id)
? { last_comment_preview: body.slice(0, 160), last_comment_by: userId }
: {}),
});
return { ok: true };
},
});
export const deleteMessage = mutation({
args: { messageId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const message = await ctx.db
.query("comment_messages")
.withIndex("by_message_id", (q: any) => q.eq("id", args.messageId))
.first();
if (!message) throw new Error("评论不存在");
if (message.deleted_at != null) return { ok: true };
const thread = await ctx.db
.query("comment_threads")
.withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id))
.first();
if (!thread) throw new Error("评论线程不存在");
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q: any) => q.eq("id", message.document_id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
if (String(message.created_by) === String(userId)) {
await requireCanViewDocument(ctx, doc, userId);
} else {
await requireCanModerateDocument(ctx, doc, userId);
}
const ts = nowIso();
await ctx.db.patch(message._id, { deleted_at: ts, deleted_by: userId });
const messages = await ctx.db
.query("comment_messages")
.withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id))
.collect();
messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at)));
const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null;
await ctx.db.patch(thread._id, {
last_activity_at: ts,
last_comment_preview: latestAlive ? String(latestAlive.body ?? "").slice(0, 160) : null,
last_comment_by: latestAlive ? String(latestAlive.created_by ?? "") : null,
});
return { ok: true };
},
});
+15
View File
@@ -0,0 +1,15 @@
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// 每周定时清理(保留最近 7 天):authRefreshTokens / authSessions / jobs
// 说明:使用 UTC 时间,避免本地时区/夏令时导致触发不稳定。
crons.weekly(
"cleanup_weekly_keep_last_7_days",
{ dayOfWeek: "sunday", hourUTC: 3, minuteUTC: 0 },
(internal as any).maintenance.cleanupWeekly,
);
export default crons;
+219
View File
@@ -178,6 +178,154 @@ async function purgeDocumentShareRelations(ctx: any, workspaceId: string, docume
}
}
async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentIds: string[]) {
const uniqueDocIds = Array.from(new Set(documentIds.filter(Boolean)));
if (uniqueDocIds.length === 0) return;
// 1) 思维导图
for (const docId of uniqueDocIds) {
const rows = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q: any) => q.eq("document_id", docId))
.collect();
for (const r of rows) {
await ctx.db.delete(r._id);
}
}
// 2) 附件(含底层 storage 文件)
const assets: any[] = [];
for (const docId of uniqueDocIds) {
const rows = await ctx.db
.query("media_assets")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
assets.push(...rows);
}
if (assets.length) {
const assetIdsToDelete = new Set<string>(assets.map((a) => String(a.id)));
const byStorage = new Map<string, string[]>();
for (const a of assets) {
const sid = (a.storage_id as any) ?? null;
if (!sid) continue;
const list = byStorage.get(String(sid)) ?? [];
list.push(String(a.id));
byStorage.set(String(sid), list);
}
for (const [sid] of byStorage.entries()) {
const refs = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", sid as any))
.collect();
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
const otherAlive = refs.some((r: any) => !assetIdsToDelete.has(String(r.id)) && !r.purged_at);
if (!otherAlive) {
try {
await ctx.storage.delete(sid as any);
} catch {
// ignore
}
}
}
for (const a of assets) {
await ctx.db.delete(a._id);
}
}
// 3) 在线表格(含行数据)
for (const docId of uniqueDocIds) {
const tables = await ctx.db
.query("document_tables")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
for (const table of tables) {
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q: any) => q.eq("table_id", table.id))
.collect();
for (const r of rows) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(table._id);
}
}
// 4) 评论
for (const docId of uniqueDocIds) {
const msgs = await ctx.db
.query("comment_messages")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
for (const m of msgs) {
await ctx.db.delete(m._id);
}
const threads = await ctx.db
.query("comment_threads")
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
.collect();
for (const t of threads) {
await ctx.db.delete(t._id);
}
}
// 5) 页面引用/反链(source/target 任一命中即删除)
const deletedRefIds = new Set<string>();
for (const docId of uniqueDocIds) {
const bySource = await ctx.db
.query("page_references")
.withIndex("by_workspace_source", (q: any) => q.eq("workspace_id", workspaceId).eq("source_page_id", docId))
.collect();
for (const r of bySource) {
const id = String(r._id);
if (deletedRefIds.has(id)) continue;
deletedRefIds.add(id);
await ctx.db.delete(r._id);
}
const byTarget = await ctx.db
.query("page_references")
.withIndex("by_workspace_target", (q: any) => q.eq("workspace_id", workspaceId).eq("target_page_id", docId))
.collect();
for (const r of byTarget) {
const id = String(r._id);
if (deletedRefIds.has(id)) continue;
deletedRefIds.add(id);
await ctx.db.delete(r._id);
}
}
// 6) 收藏(避免残留用户数据)
for (const docId of uniqueDocIds) {
const stars = await ctx.db
.query("document_stars")
.withIndex("by_workspace_document_user", (q: any) => q.eq("workspace_id", workspaceId).eq("document_id", docId))
.collect();
for (const s of stars) {
await ctx.db.delete(s._id);
}
}
// 7) 最近访问(避免“最近/历史”里出现幽灵页面)
// 说明:user_recent_pages 当前缺少按 document_id 的索引,这里先全表扫描再过滤。
// 若未来数据量变大,再考虑加 index("by_document", ["document_id"]) 或按 workspace/user 拆分索引。
const recents = await ctx.db.query("user_recent_pages").collect();
if (recents.length) {
const docIdSet = new Set(uniqueDocIds);
for (const r of recents) {
if (!docIdSet.has(String((r as any).document_id ?? ""))) continue;
await ctx.db.delete(r._id);
}
}
// 兜底:如果未来新增了其它 “document_id 外键表”,这里可以继续补充;
// 当前先把最容易产生垃圾、且已出现历史堆积的表(media_assets/mindmaps/表格/评论/引用/收藏)清理掉。
// 同时,避免误删“非 document_id 维度”的用户行为表(如 jobs 等),后续如确认需要可再补齐。
}
export const getMeta = query({
args: { id: v.string() },
handler: async (ctx, args) => {
@@ -228,9 +376,17 @@ export const getMeta = query({
show_structure: doc.show_structure ?? null,
protect_editing: doc.protect_editing ?? null,
show_word_count: doc.show_word_count ?? null,
collapse_backlinks: (doc as any).collapse_backlinks ?? null,
page_font: (doc as any).page_font ?? null,
layout_density: (doc as any).layout_density ?? null,
hide_child_pages: (doc as any).hide_child_pages ?? null,
show_block_ref_count: (doc as any).show_block_ref_count ?? null,
embed_default_block_id: (doc as any).embed_default_block_id ?? null,
word_count: doc.word_count ?? null,
character_count: doc.character_count ?? null,
block_count: doc.block_count ?? null,
todo_total_count: (doc as any).todo_total_count ?? null,
todo_done_count: (doc as any).todo_done_count ?? null,
};
},
});
@@ -674,9 +830,17 @@ export const create = mutation({
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: false,
page_font: "default",
layout_density: "normal",
hide_child_pages: false,
show_block_ref_count: false,
embed_default_block_id: null,
word_count: 0,
character_count: 0,
block_count: 0,
todo_total_count: 0,
todo_done_count: 0,
created_at: ts,
updated_at: ts,
deleted_at: null,
@@ -751,6 +915,32 @@ export const updateTitle = mutation({
},
});
export const setTemplate = mutation({
args: { id: v.string(), isTemplate: v.boolean() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.deleted_at != null) throw new Error("页面不存在");
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id !== userId) {
if (doc.access_scope === "public") throw new Error("无权限");
const perm =
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (perm !== "edit") throw new Error("无权限");
}
const ts = nowIso();
await ctx.db.patch(doc._id, { is_template: args.isTemplate, updated_at: ts });
return { ok: true, updated_at: ts };
},
});
export const move = mutation({
args: {
id: v.string(),
@@ -941,6 +1131,7 @@ export const purge = mutation({
const subtree = collectSubtree(owned, doc.id);
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
await purgeDocumentRelatedData(ctx, doc.workspace_id, subtree.map((d) => d.id));
for (const item of subtree) {
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
await ctx.db.delete(item._id);
@@ -971,6 +1162,7 @@ export const emptyTrashByWorkspace = mutation({
.collect();
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
await purgeDocumentRelatedData(ctx, args.workspaceId, toDelete.map((d) => d.id));
for (const d of toDelete) {
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
await ctx.db.delete(d._id);
@@ -991,6 +1183,12 @@ export const updateOptions = mutation({
showStructure: v.optional(v.boolean()),
protectEditing: v.optional(v.boolean()),
showWordCount: v.optional(v.boolean()),
collapseBacklinks: v.optional(v.boolean()),
pageFont: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"))),
layoutDensity: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"))),
hideChildPages: v.optional(v.boolean()),
showBlockRefCount: v.optional(v.boolean()),
embedDefaultBlockId: v.optional(v.union(v.string(), v.null())),
}),
},
handler: async (ctx, args) => {
@@ -1019,6 +1217,15 @@ export const updateOptions = mutation({
if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure;
if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing;
if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount;
if (typeof args.options.collapseBacklinks === "boolean") patch.collapse_backlinks = args.options.collapseBacklinks;
if (typeof (args.options as any).pageFont === "string") patch.page_font = (args.options as any).pageFont;
if (typeof (args.options as any).layoutDensity === "string") patch.layout_density = (args.options as any).layoutDensity;
if (typeof (args.options as any).hideChildPages === "boolean") patch.hide_child_pages = (args.options as any).hideChildPages;
if (typeof (args.options as any).showBlockRefCount === "boolean")
patch.show_block_ref_count = (args.options as any).showBlockRefCount;
if (typeof (args.options as any).embedDefaultBlockId === "string" || (args.options as any).embedDefaultBlockId === null) {
patch.embed_default_block_id = (args.options as any).embedDefaultBlockId;
}
if (Object.keys(patch).length === 0) {
throw new Error("缺少可更新的选项");
@@ -1036,6 +1243,8 @@ export const updateStats = mutation({
wordCount: v.number(),
characterCount: v.number(),
blockCount: v.number(),
todoTotal: v.optional(v.number()),
todoDone: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -1058,6 +1267,8 @@ export const updateStats = mutation({
word_count: args.wordCount,
character_count: args.characterCount,
block_count: args.blockCount,
todo_total_count: typeof args.todoTotal === "number" ? args.todoTotal : (doc as any).todo_total_count ?? 0,
todo_done_count: typeof args.todoDone === "number" ? args.todoDone : (doc as any).todo_done_count ?? 0,
updated_at: ts,
});
return { ok: true, updated_at: ts };
@@ -1113,9 +1324,17 @@ export const duplicate = mutation({
show_structure: source.show_structure ?? false,
protect_editing: source.protect_editing ?? false,
show_word_count: source.show_word_count ?? true,
collapse_backlinks: (source as any).collapse_backlinks ?? false,
page_font: (source as any).page_font ?? "default",
layout_density: (source as any).layout_density ?? "normal",
hide_child_pages: (source as any).hide_child_pages ?? false,
show_block_ref_count: (source as any).show_block_ref_count ?? false,
embed_default_block_id: (source as any).embed_default_block_id ?? null,
word_count: source.word_count ?? 0,
character_count: source.character_count ?? 0,
block_count: source.block_count ?? 0,
todo_total_count: (source as any).todo_total_count ?? 0,
todo_done_count: (source as any).todo_done_count ?? 0,
created_at: ts,
updated_at: ts,
+27 -6
View File
@@ -132,11 +132,18 @@ export const run = internalAction({
const title = meta.title ?? "无标题";
const text = extractTextFromDocumentContent(contentRes?.content ?? null);
const fileSource = `document:${documentId}`;
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
if (ingest.skipped) {
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "document", documentId, skipped: true, reason: ingest.reason ?? "skipped" },
});
return;
}
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "document", documentId, trackId },
result: { ok: true, kind: "document", documentId, trackId: ingest.trackId },
});
return;
}
@@ -165,11 +172,18 @@ export const run = internalAction({
const title = docMeta.title ?? "无标题";
const text = extractTextFromMindmapData(mindmapRes.data ?? null);
const fileSource = `mindmap:${docId}:${mindmapId}`;
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
if (ingest.skipped) {
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: ingest.reason ?? "skipped" },
});
return;
}
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId },
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId: ingest.trackId },
});
return;
}
@@ -191,11 +205,18 @@ export const run = internalAction({
return;
}
const fileSource = `media:${assetId}`;
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
if (ingest.skipped) {
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: ingest.reason ?? "skipped" },
});
return;
}
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "media_asset", assetId, trackId },
result: { ok: true, kind: "media_asset", assetId, trackId: ingest.trackId },
});
return;
}
+83
View File
@@ -0,0 +1,83 @@
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
async function cleanupByCreationTime(ctx: any, table: string, cutoffMs: number, maxDeletes: number, dryRun: boolean) {
const batchSize = 200;
let deleted = 0;
while (deleted < maxDeletes) {
const take = Math.min(batchSize, maxDeletes - deleted);
const rows = await ctx.db.query(table as any).order("asc").take(take);
if (!rows.length) break;
let reachedNewer = false;
for (const row of rows) {
const created = typeof row._creationTime === "number" ? row._creationTime : 0;
if (created >= cutoffMs) {
reachedNewer = true;
break;
}
if (!dryRun) {
await ctx.db.delete(row._id);
}
deleted += 1;
if (deleted >= maxDeletes) break;
}
if (reachedNewer) break;
}
// 说明:若本次达到上限,可能还有更多旧数据;由调用方决定是否继续调度。
return deleted;
}
export const cleanupWeekly = internalMutation({
args: {
dryRun: v.optional(v.boolean()),
maxDeletes: v.optional(v.number()),
},
handler: async (ctx, args) => {
const dryRun = Boolean(args.dryRun);
const maxDeletes = Math.max(100, Math.min(50_000, Math.floor(args.maxDeletes ?? 20_000)));
const cutoffMs = Date.now() - WEEK_MS;
// 说明:
// - authRefreshTokens/authSessions:来自 @convex-dev/auth,按“只保留最近 7 天”的要求直接清理旧记录。
// - jobs:异步任务表,保留最近 7 天即可(避免长期堆积)。
const perTable = Math.max(100, Math.floor(maxDeletes / 3));
const deletedAuthRefreshTokens = await cleanupByCreationTime(
ctx,
"authRefreshTokens",
cutoffMs,
perTable,
dryRun,
);
const deletedAuthSessions = await cleanupByCreationTime(ctx, "authSessions", cutoffMs, perTable, dryRun);
const deletedJobs = await cleanupByCreationTime(ctx, "jobs", cutoffMs, maxDeletes - perTable * 2, dryRun);
// 若达到上限,兜底再跑一轮(避免一次删太多导致超时)
const hitLimit =
deletedAuthRefreshTokens >= perTable || deletedAuthSessions >= perTable || deletedJobs >= maxDeletes - perTable * 2;
if (!dryRun && hitLimit) {
await ctx.scheduler.runAfter(60_000, (internal as any).maintenance.cleanupWeekly, { dryRun: false, maxDeletes });
}
return {
ok: true,
dryRun,
cutoffMs,
deleted: {
authRefreshTokens: deletedAuthRefreshTokens,
authSessions: deletedAuthSessions,
jobs: deletedJobs,
},
rescheduled: !dryRun && hitLimit,
};
},
});
+109 -38
View File
@@ -1,8 +1,9 @@
import { mutation, query } from "./_generated/server";
import { internalMutation, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { internal } from "./_generated/api";
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
const membership = await ctx.db
@@ -15,6 +16,41 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string
return membership;
}
function resolveGraceSeconds(): number {
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 600;
}
return Math.floor(parsed);
}
function isExpiredForPurge(deletedAt: unknown, expiredDeletedAt: string): boolean {
if (!deletedAt || typeof deletedAt !== "string") return false;
// 说明:deleted_at 使用 ISO 字符串,按字典序比较即可满足时间先后。
return deletedAt <= expiredDeletedAt;
}
async function deleteMediaAssetRow(ctx: any, row: any, deletingIds: Set<string>) {
const storageId = (row.storage_id as any) ?? null;
if (storageId) {
const refs = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", storageId))
.collect();
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
const otherAlive = refs.some((r: any) => !deletingIds.has(String(r.id)) && !r.purged_at);
if (!otherAlive) {
try {
await ctx.storage.delete(storageId);
} catch {
// ignore
}
}
}
await ctx.db.delete(row._id);
}
function shouldExtractAttachmentText(args: {
assetType?: string | null;
mimeType?: string | null;
@@ -340,6 +376,8 @@ export const patchById = mutation({
if (!row) throw new Error("资源不存在");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
const prevDeletedAt = (row as any).deleted_at ?? null;
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
await ctx.db.patch(row._id, next);
@@ -351,6 +389,21 @@ export const patchById = mutation({
if (nextOcrStatus === "completed" && typeof nextOcrText === "string" && nextOcrText.trim()) {
await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 1200 });
}
// 说明:当附件进入垃圾桶时,自动调度“到期彻底删除”,避免长期堆积垃圾数据。
const nextDeletedAt =
patch.deleted_at === null
? null
: typeof patch.deleted_at === "string"
? patch.deleted_at
: ((row as any).deleted_at ?? null);
if (prevDeletedAt == null && typeof nextDeletedAt === "string" && nextDeletedAt.trim()) {
const graceMs = (resolveGraceSeconds() + 5) * 1000;
await ctx.scheduler.runAfter(graceMs, internal.mediaAssets.purgeIfExpired, {
id: args.id,
deletedAt: nextDeletedAt,
});
}
return { ok: true };
},
});
@@ -463,34 +516,53 @@ export const purgeById = mutation({
if (!row) throw new Error("未找到附件");
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
if (row.purged_at) {
return { ok: true, alreadyPurged: true };
// 仅允许清理“已进入垃圾桶且超出宽限期”的附件;清空垃圾桶会传入一个很靠后的 expiredDeletedAt 以强制清理。
if (!isExpiredForPurge((row as any).deleted_at, args.expiredDeletedAt)) {
return { ok: false, reason: "not_expired" as const };
}
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
return { ok: true, deleted: 1 };
},
});
export const purgeIfExpired = internalMutation({
args: { id: v.string(), deletedAt: v.string() },
handler: async (ctx, args) => {
const row = await ctx.db
.query("media_assets")
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
.first();
if (!row) {
return { ok: true, deleted: false, reason: "not_found" as const };
}
const storageId = (row.storage_id as any) ?? null;
if (storageId) {
const refs = await ctx.db
.query("media_assets")
.withIndex("by_storage_id", (q) => q.eq("storage_id", storageId))
.collect();
const otherAlive = refs.some((r) => r.id !== row.id && !r.purged_at);
if (!otherAlive) {
await ctx.storage.delete(storageId);
if ((row as any).deleted_at == null) {
return { ok: true, deleted: false, reason: "restored" as const };
}
if ((row as any).deleted_at !== args.deletedAt) {
// 删除时间不一致:说明已被恢复/重新删除过,旧调度作废
return { ok: true, deleted: false, reason: "changed" as const };
}
const graceSeconds = resolveGraceSeconds();
const deletedTs = Date.parse(String((row as any).deleted_at));
if (Number.isFinite(deletedTs)) {
const elapsedMs = Date.now() - deletedTs;
const graceMs = graceSeconds * 1000;
if (elapsedMs < graceMs) {
const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000);
await ctx.scheduler.runAfter(remainingMs, internal.mediaAssets.purgeIfExpired, {
id: args.id,
deletedAt: args.deletedAt,
});
return { ok: true, deleted: false, rescheduled: true, remainingMs };
}
}
const ts = nowIso();
await ctx.db.patch(row._id, {
deleted_at: args.expiredDeletedAt,
deleted_by: args.userId,
purged_at: ts,
file_url: null,
thumbnail_url: null,
storage_id: null,
updated_at: ts,
});
return { ok: true };
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
return { ok: true, deleted: true };
},
});
@@ -505,7 +577,13 @@ export const emptyTrashByWorkspace = mutation({
.order("desc")
.take(5000);
const targets = rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at);
const targets = rows.filter((r) => {
// 说明:
// - 清空垃圾桶是强制操作,route 会传一个很靠后的 expiredDeletedAt
// - 这里也顺手清掉历史 purged_at 墓碑(避免长期堆积)
if (r.purged_at) return true;
return isExpiredForPurge((r as any).deleted_at, args.expiredDeletedAt);
});
if (targets.length === 0) {
return { ok: true, updated: 0 };
}
@@ -525,8 +603,10 @@ export const emptyTrashByWorkspace = mutation({
.query("media_assets")
.withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any))
.collect();
const alive = refs.some((r) => !r.purged_at && !r.deleted_at);
if (!alive) {
const deletingIds = new Set(byStorage.get(sid) ?? []);
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
const otherAlive = refs.some((r) => !deletingIds.has(r.id) && !r.purged_at);
if (!otherAlive) {
try {
await ctx.storage.delete(sid as any);
} catch {
@@ -535,19 +615,10 @@ export const emptyTrashByWorkspace = mutation({
}
}
const ts = nowIso();
for (const r of targets) {
await ctx.db.patch(r._id, {
deleted_at: args.expiredDeletedAt,
deleted_by: args.userId,
purged_at: ts,
file_url: null,
thumbnail_url: null,
storage_id: null,
updated_at: ts,
});
await ctx.db.delete(r._id);
}
return { ok: true, updated: targets.length };
return { ok: true, updated: targets.length, deleted: targets.length };
},
});
+70 -1
View File
@@ -1,14 +1,24 @@
import { internalQuery, mutation, query } from "./_generated/server";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
import { internal } from "./_generated/api";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
function resolveGraceSeconds(): number {
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 600;
}
return Math.floor(parsed);
}
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId === null) {
@@ -195,12 +205,71 @@ export const softDelete = mutation({
return { ok: true, moved: 0 };
}
if (existing.deleted_at != null) {
// 已在垃圾桶:保持幂等,避免重复删除导致 updated_at 抖动/重复调度
return { ok: true, moved: 0, deleted_at: existing.deleted_at };
}
const ts = nowIso();
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
// 10 分钟宽限(可恢复);到期自动清理(彻底删除)。
// 说明:这里用 scheduler.runAfter 做“按条目调度”,无需全局 cron。
const graceMs = (resolveGraceSeconds() + 5) * 1000;
await ctx.scheduler.runAfter(graceMs, internal.mindmaps.purgeIfExpired, {
docId: args.docId,
mindmapId,
deletedAt: ts,
});
return { ok: true, moved: 1, deleted_at: ts };
},
});
export const purgeIfExpired = internalMutation({
args: { docId: v.string(), mindmapId: v.string(), deletedAt: v.string() },
handler: async (ctx, args) => {
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db
.query("mindmaps")
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
.first();
if (!existing) {
return { ok: true, deleted: false, reason: "not_found" as const };
}
if (existing.deleted_at == null) {
return { ok: true, deleted: false, reason: "restored" as const };
}
if (existing.deleted_at !== args.deletedAt) {
// 删除时间不一致:说明已被恢复/重新删除过,旧调度作废
return { ok: true, deleted: false, reason: "changed" as const };
}
const graceSeconds = resolveGraceSeconds();
const deletedTs = Date.parse(existing.deleted_at);
if (Number.isFinite(deletedTs)) {
const elapsedMs = Date.now() - deletedTs;
const graceMs = graceSeconds * 1000;
if (elapsedMs < graceMs) {
// 兜底:调度延迟/时钟偏差时,重新排一次剩余时间
const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000);
await ctx.scheduler.runAfter(remainingMs, internal.mindmaps.purgeIfExpired, {
docId: args.docId,
mindmapId,
deletedAt: args.deletedAt,
});
return { ok: true, deleted: false, rescheduled: true, remainingMs };
}
}
await ctx.db.delete(existing._id);
return { ok: true, deleted: true };
},
});
export const restore = mutation({
args: { docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
+53
View File
@@ -52,11 +52,19 @@ export default defineSchema({
show_structure: v.union(v.boolean(), v.null()),
protect_editing: v.union(v.boolean(), v.null()),
show_word_count: v.union(v.boolean(), v.null()),
collapse_backlinks: v.optional(v.union(v.boolean(), v.null())),
page_font: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"), v.null())),
layout_density: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"), v.null())),
hide_child_pages: v.optional(v.union(v.boolean(), v.null())),
show_block_ref_count: v.optional(v.union(v.boolean(), v.null())),
embed_default_block_id: v.optional(v.union(v.string(), v.null())),
// 统计信息(由客户端编辑器计算后回写)
word_count: v.union(v.number(), v.null()),
character_count: v.union(v.number(), v.null()),
block_count: v.union(v.number(), v.null()),
todo_total_count: v.optional(v.union(v.number(), v.null())),
todo_done_count: v.optional(v.union(v.number(), v.null())),
// 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。
content: v.any(),
@@ -203,6 +211,10 @@ export default defineSchema({
view_preferences: v.any(),
snapshot: v.optional(v.any()), // DocumentTableSnapshot: { rows?, luckysheet? }
is_archived: v.boolean(),
// 垃圾桶字段:与附件/思维导图的删除语义保持一致(允许历史数据缺字段)
deleted_at: v.optional(v.union(v.string(), v.null())),
deleted_by: v.optional(v.union(v.string(), v.null())),
purged_at: v.optional(v.union(v.string(), v.null())),
last_synced_at: v.optional(v.string()),
created_by: v.string(),
updated_by: v.optional(v.string()),
@@ -254,9 +266,50 @@ export default defineSchema({
.index("by_created_by", ["created_by"])
.index("by_document", ["document_id"])
.index("by_doc_user", ["document_id", "shared_with_user_id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"])
.index("by_workspace_created_by", ["workspace_id", "created_by"]),
// M9:评论(页面/块)
comment_threads: defineTable({
id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
block_id: v.union(v.string(), v.null()),
resolved_at: v.union(v.string(), v.null()),
resolved_by: v.union(v.string(), v.null()),
comment_count: v.number(),
last_activity_at: v.string(),
last_comment_preview: v.union(v.string(), v.null()),
last_comment_by: v.union(v.string(), v.null()),
created_by: v.string(),
created_at: v.string(),
})
.index("by_thread_id", ["id"])
.index("by_document", ["document_id"])
.index("by_document_block", ["document_id", "block_id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_document_activity", ["workspace_id", "document_id", "last_activity_at"]),
comment_messages: defineTable({
id: v.string(),
thread_id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
block_id: v.union(v.string(), v.null()),
parent_id: v.union(v.string(), v.null()),
body: v.string(),
created_by: v.string(),
created_at: v.string(),
deleted_at: v.union(v.string(), v.null()),
deleted_by: v.union(v.string(), v.null()),
})
.index("by_message_id", ["id"])
.index("by_thread", ["thread_id"])
.index("by_document", ["document_id"])
.index("by_document_block", ["document_id", "block_id"])
.index("by_workspace", ["workspace_id"]),
groups: defineTable({
id: v.string(),
workspace_id: v.string(),
+76 -1
View File
@@ -142,6 +142,9 @@ export const listByWorkspaceForSearch = query({
document_id: t.document_id,
title: t.title,
is_archived: t.is_archived,
deleted_at: t.deleted_at ?? null,
deleted_by: t.deleted_by ?? null,
purged_at: t.purged_at ?? null,
created_at: t.created_at,
updated_at: t.updated_at,
}));
@@ -173,6 +176,9 @@ export const create = mutation({
view_preferences: {},
snapshot: args.snapshot ?? null,
is_archived: false,
deleted_at: null,
deleted_by: null,
purged_at: null,
last_synced_at: now,
created_by: args.userId,
updated_by: args.userId,
@@ -279,9 +285,39 @@ export const remove = mutation({
throw new Error("Table not found");
}
const now = nowIso();
await ctx.db.patch(table._id, {
is_archived: true,
updated_at: nowIso(),
deleted_at: now,
deleted_by: args.userId,
purged_at: null,
updated_at: now,
updated_by: args.userId,
});
return { success: true };
},
});
// Mutation: 恢复表格(从垃圾桶恢复)
export const restore = mutation({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) {
throw new Error("Table not found");
}
const now = nowIso();
await ctx.db.patch(table._id, {
is_archived: false,
deleted_at: null,
deleted_by: null,
purged_at: null,
updated_at: now,
updated_by: args.userId,
});
@@ -318,6 +354,45 @@ export const purge = mutation({
},
});
export const emptyTrashByWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string(), expiredDeletedAt: v.string() },
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const rows = await ctx.db
.query("document_tables")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(5000);
// 仅清理“已进入垃圾桶且超出宽限期”的表格
const targets = rows.filter((t) => {
if (!t.is_archived) return false;
const deletedAt = (t as any).deleted_at ?? null;
if (!deletedAt || typeof deletedAt !== "string") return false;
return deletedAt <= args.expiredDeletedAt;
});
if (targets.length === 0) {
return { ok: true, deleted: 0 };
}
for (const table of targets) {
const tableId = table.id;
const tableRows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", tableId))
.collect();
for (const r of tableRows) {
await ctx.db.delete(r._id);
}
await ctx.db.delete(table._id);
}
return { ok: true, deleted: targets.length };
},
});
// Query: 获取表格行数据
export const getRows = query({
args: { tableId: v.string() },