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; } async function cleanupOrphanConvexFiles( ctx: any, args: { cutoffMs: number; maxDeletes: number; dryRun: boolean }, ): Promise<{ deleted: number; scanned: number; hitLimit: boolean }> { const batchSize = 200; let deleted = 0; let scanned = 0; let lastCreationTime: number | null = null; let lastId: any = null; while (deleted < args.maxDeletes) { let q = ctx.db.system.query("_storage").order("asc"); if (lastCreationTime != null && lastId != null) { // 说明:Convex 的 `paginate` 每个函数只能调用一次,这里用“游标过滤 + take”模拟分页。 // 使用 (_creationTime, _id) 作为游标,避免同一毫秒内多条记录导致遗漏/死循环。 q = q.filter((qb: any) => qb.or( qb.gt(qb.field("_creationTime"), lastCreationTime), qb.and(qb.eq(qb.field("_creationTime"), lastCreationTime), qb.gt(qb.field("_id"), lastId)), ), ); } const rows = await q.take(batchSize); if (!rows.length) break; let reachedNewer = false; for (const row of rows) { scanned += 1; const created = typeof row._creationTime === "number" ? row._creationTime : 0; lastCreationTime = created; lastId = row._id; if (created >= args.cutoffMs) { reachedNewer = true; break; } // 说明:当前仓库里,Convex Files 仅用于 media_assets.storage_id。 // 若未来新增其它引用表,需要把引用检查一并补齐,避免误删仍被使用的文件。 const ref = await ctx.db .query("media_assets") .withIndex("by_storage_id", (q: any) => q.eq("storage_id", row._id)) .first(); if (ref) continue; if (!args.dryRun) { try { await ctx.storage.delete(row._id); } catch { // ignore } } deleted += 1; if (deleted >= args.maxDeletes) break; } if (reachedNewer) break; } return { deleted, scanned, hitLimit: deleted >= args.maxDeletes }; } export const cleanupWeekly = internalMutation({ args: { dryRun: v.optional(v.boolean()), maxDeletes: v.optional(v.number()), maxFileDeletes: 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 maxFileDeletes = Math.max(100, Math.min(50_000, Math.floor(args.maxFileDeletes ?? 10_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); // 说明:清理 Convex Files 孤儿数据(_storage 里“7 天前且无引用”的文件)。 const files = await cleanupOrphanConvexFiles(ctx, { cutoffMs, maxDeletes: maxFileDeletes, dryRun }); // 若达到上限,兜底再跑一轮(避免一次删太多导致超时) const hitLimit = deletedAuthRefreshTokens >= perTable || deletedAuthSessions >= perTable || deletedJobs >= maxDeletes - perTable * 2 || files.hitLimit; if (!dryRun && hitLimit) { await ctx.scheduler.runAfter(60_000, (internal as any).maintenance.cleanupWeekly, { dryRun: false, maxDeletes, maxFileDeletes, }); } return { ok: true, dryRun, cutoffMs, deleted: { authRefreshTokens: deletedAuthRefreshTokens, authSessions: deletedAuthSessions, jobs: deletedJobs, convexFiles: files.deleted, }, scanned: { convexFiles: files.scanned, }, rescheduled: !dryRun && hitLimit, }; }, });