Files
mnote/wolai-frontend/convex/tables.ts
T
2026-04-13 19:21:42 +08:00

444 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { generateId } from "./_utils/id";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
return membership;
}
// Query: 获取单个表格
export const get = query({
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) return null;
if (table.workspace_id) {
// 验证用户是否在工作区中(简化版:假设 userId 有效)
// TODO: 添加 workspace_members 验证
}
return {
id: table.id,
workspace_id: table.workspace_id,
document_id: table.document_id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
view_preferences: table.view_preferences,
snapshot: table.snapshot ?? null,
is_archived: table.is_archived,
last_synced_at: table.last_synced_at ?? null,
created_by: table.created_by,
updated_by: table.updated_by ?? null,
created_at: table.created_at,
updated_at: table.updated_at,
};
},
});
// Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook
export const getByGridKey = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回 Luckysheet 需要的格式
return {
title: table.title,
gridKey: table.grid_key,
lang: "zh",
};
},
});
// Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load
export const getByGridKeyFull = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回完整的表格数据,包括 schema 和 snapshot
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot ?? null,
};
},
});
// Query: 列出文档的所有表格
export const listByDocument = query({
args: { userId: v.string(), documentId: v.string() },
handler: async (ctx, args) => {
const tables = await ctx.db
.query("document_tables")
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
.collect();
return tables
.filter((t) => !t.is_archived)
.map((t) => ({
id: t.id,
document_id: t.document_id,
grid_key: t.grid_key,
title: t.title,
schema: t.schema,
view_preferences: t.view_preferences,
snapshot: t.snapshot ?? null,
is_archived: t.is_archived,
last_synced_at: t.last_synced_at ?? null,
created_at: t.created_at,
updated_at: t.updated_at,
}));
},
});
export const listByWorkspaceForSearch = query({
args: {
userId: v.string(),
workspaceId: v.string(),
includeArchived: v.optional(v.boolean()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const includeArchived = Boolean(args.includeArchived);
const limit = Math.max(1, Math.min(3000, Math.floor(args.limit ?? 3000)));
const rows = await ctx.db
.query("document_tables")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 2);
return rows
.filter((t) => (includeArchived ? true : !t.is_archived))
.slice(0, limit)
.map((t) => ({
id: t.id,
workspace_id: t.workspace_id,
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,
}));
},
});
// Mutation: 创建表格
export const create = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
documentId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
snapshot: v.optional(v.any()),
},
handler: async (ctx, args) => {
const now = nowIso();
const tableId = generateId();
const gridKey = generateId(); // 用于 WebSocket 协同标识
const table = {
id: tableId,
workspace_id: args.workspaceId,
document_id: args.documentId,
grid_key: gridKey,
title: args.title ?? "未命名表格",
schema: args.schema ?? {},
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,
created_at: now,
updated_at: now,
};
await ctx.db.insert("document_tables", table);
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot,
};
},
});
// Mutation: 更新表格
export const update = mutation({
args: {
userId: v.string(),
tableId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
view_preferences: v.optional(v.any()),
snapshot: v.optional(v.any()),
rows: v.optional(v.any()),
},
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();
const updates: any = {
updated_at: now,
updated_by: args.userId,
last_synced_at: now,
};
if (args.title !== undefined) updates.title = args.title;
if (args.schema !== undefined) updates.schema = args.schema;
if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences;
if (args.snapshot !== undefined) updates.snapshot = args.snapshot;
await ctx.db.patch(table._id, updates);
// 如果提供了 rows,更新行数据
if (args.rows && Array.isArray(args.rows)) {
// 先删除旧行
const existingRows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of existingRows) {
await ctx.db.delete(row._id);
}
// 插入新行
for (const [index, rowData] of args.rows.entries()) {
await ctx.db.insert("document_table_rows", {
id: generateId(),
workspace_id: table.workspace_id,
document_id: table.document_id,
table_id: args.tableId,
row_index: index,
row_data: rowData,
row_hash: JSON.stringify(rowData),
is_deleted: false,
updated_by: args.userId,
created_at: now,
updated_at: now,
});
}
}
return {
id: table.id,
title: updates.title ?? table.title,
schema: updates.schema ?? table.schema,
view_preferences: updates.view_preferences ?? table.view_preferences,
snapshot: updates.snapshot ?? table.snapshot,
updated_at: now,
};
},
});
// Mutation: 删除表格(软删除)
export const remove = 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: true,
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,
});
return { success: true };
},
});
// Mutation: 永久删除表格
export const purge = 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 rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of rows) {
await ctx.db.delete(row._id);
}
// 删除表格
await ctx.db.delete(table._id);
return { success: true };
},
});
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() },
handler: async (ctx, args) => {
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
return rows
.filter((r) => !r.is_deleted)
.sort((a, b) => a.row_index - b.row_index)
.map((r) => r.row_data);
},
});
export const listRowsByWorkspaceForSearch = query({
args: {
userId: v.string(),
workspaceId: v.string(),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
const limit = Math.max(1, Math.min(8000, Math.floor(args.limit ?? 8000)));
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.order("desc")
.take(limit * 2);
return rows
.filter((r) => !r.is_deleted)
.slice(0, limit)
.map((r) => ({
id: r.id,
workspace_id: r.workspace_id,
document_id: r.document_id,
table_id: r.table_id,
row_index: r.row_index,
row_hash: r.row_hash ?? null,
created_at: r.created_at ?? null,
updated_at: r.updated_at ?? null,
}));
},
});