feat: 完成 rust cutover phase 8 收口

This commit is contained in:
lix-2026
2026-04-15 20:01:12 +08:00
parent 822c730cd6
commit 98db79b301
104 changed files with 18777 additions and 3025 deletions
+159
View File
@@ -14,6 +14,61 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
return member;
}
function sortByNewest<T extends Record<string, any>>(rows: T[]) {
return [...rows].sort((left, right) => {
const leftTime = String(left.created_at ?? left.finished_at ?? "");
const rightTime = String(right.created_at ?? right.finished_at ?? "");
return rightTime.localeCompare(leftTime);
});
}
function decodeCursor(raw: string | null | undefined) {
if (!raw) return null;
try {
const decoded = JSON.parse(raw) as {
createdAt?: string | null;
id?: string | null;
};
const createdAt = typeof decoded.createdAt === "string" ? decoded.createdAt : "";
const id = typeof decoded.id === "string" ? decoded.id : "";
if (!createdAt || !id) return null;
return { createdAt, id };
} catch {
return null;
}
}
function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) {
if (!row?.created_at || !row?.id) return null;
return JSON.stringify({
createdAt: row.created_at,
id: row.id,
});
}
function matchesCursor<T extends Record<string, any>>(
row: T,
cursor: { createdAt: string; id: string } | null,
) {
if (!cursor) return true;
const createdAt = String(row.created_at ?? row.finished_at ?? "");
const id = String(row.id ?? "");
if (!createdAt || !id) return false;
if (createdAt < cursor.createdAt) return true;
if (createdAt > cursor.createdAt) return false;
return id < cursor.id;
}
function normalizeStatusFilter(raw: string | null | undefined) {
const normalized = String(raw ?? "").trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeObjectFilter(raw: string | null | undefined) {
const normalized = String(raw ?? "").trim();
return normalized.length > 0 ? normalized : null;
}
export const recordCommandLog = mutation({
args: {
workspaceId: v.string(),
@@ -171,3 +226,107 @@ export const listByRequest = query({
};
},
});
export const listByCommand = query({
args: { workspaceId: v.string(), commandId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireWorkspaceMember(ctx, args.workspaceId, userId);
const commandLogs = await ctx.db
.query("command_logs")
.withIndex("by_workspace_command", (q) =>
q.eq("workspace_id", args.workspaceId).eq("command_id", args.commandId),
)
.collect();
const domainEvents = await ctx.db
.query("domain_events")
.withIndex("by_workspace_command", (q) =>
q.eq("workspace_id", args.workspaceId).eq("command_id", args.commandId),
)
.collect();
return {
command_id: args.commandId,
command_logs: commandLogs,
domain_events: domainEvents,
generated_at: nowIso(),
};
},
});
export const listWorkspaceOverview = query({
args: {
workspaceId: v.string(),
limit: v.optional(v.number()),
cursor: v.optional(v.union(v.string(), v.null())),
commandStatus: v.optional(v.union(v.string(), v.null())),
eventStatus: v.optional(v.union(v.string(), v.null())),
targetPageId: v.optional(v.union(v.string(), v.null())),
targetBlockId: v.optional(v.union(v.string(), v.null())),
aggregateType: v.optional(v.union(v.string(), v.null())),
aggregateId: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
await requireWorkspaceMember(ctx, args.workspaceId, userId);
const limit = Math.max(1, Math.min(100, Math.floor(args.limit ?? 50)));
const cursor = decodeCursor(args.cursor ?? null);
const commandStatus = normalizeStatusFilter(args.commandStatus);
const eventStatus = normalizeStatusFilter(args.eventStatus);
const targetPageId = normalizeObjectFilter(args.targetPageId);
const targetBlockId = normalizeObjectFilter(args.targetBlockId);
const aggregateType = normalizeObjectFilter(args.aggregateType);
const aggregateId = normalizeObjectFilter(args.aggregateId);
const allCommandLogs = await ctx.db
.query("command_logs")
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const allDomainEvents = await ctx.db
.query("domain_events")
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const filteredCommandLogs = sortByNewest(
allCommandLogs.filter((row: any) => {
if (commandStatus && row.status !== commandStatus) return false;
if (targetPageId && String(row.target_page_id ?? "") !== targetPageId) return false;
if (targetBlockId && String(row.target_block_id ?? "") !== targetBlockId) return false;
return matchesCursor(row, cursor);
}),
);
const pageCommandLogs = filteredCommandLogs.slice(0, limit);
const nextCursor = encodeCursor(pageCommandLogs[pageCommandLogs.length - 1] ?? null);
const commandIds = new Set(pageCommandLogs.map((row: any) => String(row.command_id)));
const domainEvents = sortByNewest(
allDomainEvents.filter((row: any) => {
if (commandIds.size > 0 && !commandIds.has(String(row.command_id ?? ""))) return false;
if (eventStatus && row.status !== eventStatus) return false;
if (aggregateType && String(row.aggregate_type ?? "") !== aggregateType) return false;
if (aggregateId && String(row.aggregate_id ?? "") !== aggregateId) return false;
return true;
}),
);
return {
workspace_id: args.workspaceId,
command_logs: pageCommandLogs,
domain_events: domainEvents,
next_cursor: nextCursor,
has_more: filteredCommandLogs.length > pageCommandLogs.length,
filters: {
command_status: commandStatus,
event_status: eventStatus,
target_page_id: targetPageId,
target_block_id: targetBlockId,
aggregate_type: aggregateType,
aggregate_id: aggregateId,
},
generated_at: nowIso(),
};
},
});
+543
View File
@@ -1,4 +1,5 @@
import { internalQuery, mutation, query } from "./_generated/server";
import { api } from "./_generated/api";
import { v } from "convex/values";
import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time";
@@ -13,6 +14,352 @@ type SharePermission = "read" | "edit";
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
type CopyTreeDocument = {
_id: unknown;
id: string;
title: string | null;
parent_id: string | null;
workspace_id: string;
access_scope: "private" | "shared" | "public" | null;
sort_order: number | null;
created_at: string | null;
content: unknown;
user_id: string;
deleted_at: string | null;
};
function normalizeTitle(title: string | null | undefined): string {
const safe = String(title ?? "").trim();
return safe.length > 0 ? safe : "无标题";
}
function makeUniqueTitle(baseTitle: string, existing: Set<string>): string {
const base = normalizeTitle(baseTitle);
if (!existing.has(base)) {
existing.add(base);
return base;
}
const first = `${base} 副本`;
if (!existing.has(first)) {
existing.add(first);
return first;
}
for (let i = 2; i < 1000; i += 1) {
const candidate = `${base} 副本 ${i}`;
if (!existing.has(candidate)) {
existing.add(candidate);
return candidate;
}
}
const fallback = `${base} 副本 ${Date.now()}`;
existing.add(fallback);
return fallback;
}
function extractBlocksFromContent(content: unknown): unknown[] {
if (Array.isArray(content)) {
return content as unknown[];
}
if (content && typeof content === "object" && Array.isArray((content as { blocks?: unknown[] }).blocks)) {
return ((content as { blocks?: unknown[] }).blocks ?? []) as unknown[];
}
return [];
}
function composeContentWithBlocks(content: unknown, blocks: unknown[]): unknown {
if (Array.isArray(content)) {
return blocks as unknown[];
}
if (content && typeof content === "object") {
return {
...(content as Record<string, unknown>),
blocks,
};
}
return { blocks };
}
function appendPageReferenceBlock(content: unknown, pageId: string, title: string): unknown {
const blocks = extractBlocksFromContent(content);
const pageReferenceBlock = {
id: typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`,
type: "pageReference",
props: {
pageId,
title: normalizeTitle(title),
},
};
return composeContentWithBlocks(content, [...blocks, pageReferenceBlock]);
}
function getChildrenSorted(childrenByParent: Map<string | null, CopyTreeDocument[]>, parentId: string | null): CopyTreeDocument[] {
const list = childrenByParent.get(parentId) ?? [];
return [...list].sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
const timeA = new Date(a.created_at ?? 0).getTime();
const timeB = new Date(b.created_at ?? 0).getTime();
return timeA - timeB;
});
}
async function copyMindmapsForDocument(ctx: any, sourceDocId: string, targetDocId: string) {
return await ctx.runMutation(api.mindmaps.copyByDocument, {
sourceDocId,
targetDocId,
});
}
async function assertDocumentVisibleToUser(ctx: any, doc: any, userId: string, errorMessage = "无权限") {
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
if (doc.user_id === userId) {
return;
}
if (doc.access_scope === "public") {
return;
}
const policy = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!policy) {
throw new Error(errorMessage);
}
}
async function createDocumentRecord(
ctx: any,
args: {
id: string;
workspaceId: string;
parentId: string | null;
title?: string | null;
accessScope: "private" | "shared" | "public";
content?: unknown;
},
) {
const userId = await requireUserId(ctx);
const existing = await getCanonicalDocumentByBusinessId<any>(ctx, args.id);
if (existing && existing.deleted_at == null) {
throw new Error("页面已存在");
}
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q: any) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId))
.collect();
const sortOrder = siblings.filter((d: any) => d.deleted_at == null).length;
const ts = nowIso();
const title = (args.title ?? "无标题") || "无标题";
const content = typeof args.content === "undefined" ? [] : args.content;
const rawText = extractTextFromDocumentContent(content);
await ctx.db.insert("documents", {
id: args.id,
user_id: userId,
workspace_id: args.workspaceId,
parent_id: args.parentId,
title,
content,
content_revision: 0,
content_conflict_key: `${args.id}:0`,
raw_text: rawText,
access_scope: args.accessScope,
sort_order: sortOrder,
is_starred: false,
is_template: false,
wide_layout: false,
use_small_text: false,
show_heading_numbers: true,
show_toc: false,
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,
deleted_by: null,
});
return {
id: args.id,
title,
parent_id: args.parentId,
sort_order: sortOrder,
is_starred: false,
created_at: ts,
updated_at: ts,
workspace_id: args.workspaceId,
access_scope: args.accessScope,
is_template: false,
};
}
async function updateDocumentContentRecord(
ctx: any,
args: {
id: string;
content: unknown;
expectedRevision?: number | null;
conflictDetectionKey?: string | null;
},
) {
const userId = await requireUserId(ctx);
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);
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 currentRevision = doc.content_revision ?? 0;
const currentConflictDetectionKey =
doc.content_conflict_key ??
`${args.id}:${currentRevision}`;
if (
typeof args.expectedRevision === "number" &&
Number.isInteger(args.expectedRevision) &&
args.expectedRevision >= 0 &&
args.expectedRevision !== currentRevision
) {
throw new Error("正文内容已变更,请刷新后重试");
}
if (
typeof args.conflictDetectionKey === "string" &&
args.conflictDetectionKey.trim() &&
args.conflictDetectionKey.trim() !== currentConflictDetectionKey
) {
throw new Error("正文冲突检测失败,请刷新后重试");
}
const ts = nowIso();
const rawText = extractTextFromDocumentContent(args.content);
const nextRevision = currentRevision + 1;
const nextConflictDetectionKey = `${args.id}:${nextRevision}`;
await ctx.db.patch(doc._id, {
content: args.content,
content_revision: nextRevision,
content_conflict_key: nextConflictDetectionKey,
raw_text: rawText,
updated_at: ts,
});
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 });
return {
ok: true,
updated_at: ts,
revision: nextRevision,
conflict_detection_key: nextConflictDetectionKey,
};
}
async function duplicateDocumentRecord(
ctx: any,
args: {
sourceId: string;
newId: string;
title?: string | null;
},
) {
const userId = await requireUserId(ctx);
const source = await getCanonicalDocumentByBusinessId<any>(ctx, args.sourceId);
if (!source) throw new Error("页面不存在或无权限访问");
if (source.user_id !== userId) throw new Error("页面不存在或无权限访问");
const existingTarget = await getCanonicalDocumentByBusinessId(ctx, args.newId);
if (existingTarget && existingTarget.deleted_at == null) {
throw new Error("目标页面已存在");
}
const siblings = await ctx.db
.query("documents")
.withIndex("by_workspace_parent", (q: any) =>
q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id),
)
.collect();
const sortOrder = siblings.filter((d: any) => d.deleted_at == null).length;
const ts = nowIso();
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
const rawText = extractTextFromDocumentContent(source.content ?? []);
await ctx.db.insert("documents", {
id: args.newId,
user_id: userId,
workspace_id: source.workspace_id,
parent_id: source.parent_id,
title,
content: source.content ?? [],
content_revision: 0,
content_conflict_key: `${args.newId}:0`,
raw_text: rawText,
access_scope: source.access_scope,
sort_order: sortOrder,
is_starred: false,
is_template: false,
wide_layout: source.wide_layout ?? false,
use_small_text: source.use_small_text ?? false,
show_heading_numbers: source.show_heading_numbers ?? true,
show_toc: source.show_toc ?? false,
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,
deleted_at: null,
deleted_by: null,
});
return {
id: args.newId,
title,
parent_id: source.parent_id ?? null,
sort_order: sortOrder,
workspace_id: source.workspace_id,
access_scope: source.access_scope,
created_at: ts,
updated_at: ts,
};
}
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const member = await ctx.db
.query("workspace_members")
@@ -862,6 +1209,55 @@ export const create = mutation({
},
});
export const createWithParentReference = mutation({
args: {
id: v.string(),
workspaceId: v.string(),
parentId: v.union(v.string(), v.null()),
title: v.optional(v.union(v.string(), v.null())),
accessScope,
content: v.optional(v.any()),
},
handler: async (ctx, args) => {
if (!args.parentId) {
return await createDocumentRecord(ctx, {
id: args.id,
workspaceId: args.workspaceId,
parentId: args.parentId,
title: args.title ?? null,
accessScope: args.accessScope,
content: args.content,
});
}
const userId = await requireUserId(ctx);
const parentDoc = await getCanonicalDocumentByBusinessId<any>(ctx, args.parentId);
if (!parentDoc) {
throw new Error("父页面不存在或无权限");
}
if (parentDoc.deleted_at != null) {
throw new Error("父页面不存在或无权限");
}
await assertDocumentVisibleToUser(ctx, parentDoc, userId, "父页面不存在或无权限");
const created = await createDocumentRecord(ctx, {
...args,
workspaceId: parentDoc.workspace_id,
parentId: args.parentId,
accessScope: (parentDoc.access_scope ?? "private") as "private" | "shared" | "public",
content: args.content ?? [],
});
const updatedContent = appendPageReferenceBlock(parentDoc.content ?? null, created.id, created.title ?? "无标题");
await updateDocumentContentRecord(ctx, {
id: args.parentId,
content: updatedContent,
});
return created;
},
});
export const updateContent = mutation({
args: {
id: v.string(),
@@ -1325,6 +1721,8 @@ export const duplicate = mutation({
parent_id: source.parent_id,
title,
content: source.content ?? [],
content_revision: 0,
content_conflict_key: `${args.newId}:0`,
raw_text: rawText,
access_scope: source.access_scope,
sort_order: sortOrder,
@@ -1369,6 +1767,151 @@ export const duplicate = mutation({
},
});
export const duplicateWithMindmaps = mutation({
args: {
sourceId: v.string(),
newId: v.string(),
title: v.optional(v.union(v.string(), v.null())),
},
handler: async (ctx, args) => {
const duplicated = await duplicateDocumentRecord(ctx, args);
await copyMindmapsForDocument(ctx, args.sourceId, duplicated.id);
return duplicated;
},
});
export const copyTree = mutation({
args: {
items: v.array(
v.object({
documentId: v.string(),
recursive: v.boolean(),
}),
),
targetParentId: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const normalizedItems = args.items.filter((item) => Boolean(item?.documentId));
if (normalizedItems.length === 0) {
throw new Error("items 为空");
}
let workspaceId: string | null = null;
if (args.targetParentId) {
const targetDoc = await getCanonicalDocumentByBusinessId<any>(ctx, args.targetParentId);
if (!targetDoc) {
throw new Error("目标页面不存在或无权限");
}
if (targetDoc.deleted_at != null) {
throw new Error("目标页面不存在或无权限");
}
await assertDocumentVisibleToUser(ctx, targetDoc, userId, "目标页面不存在或无权限");
workspaceId = targetDoc.workspace_id;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
const allDocs: CopyTreeDocument[] = [];
for (const sourceId of sourceIds) {
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, sourceId) as CopyTreeDocument | null;
if (!doc || doc.deleted_at != null || doc.user_id !== userId) {
throw new Error("源页面不存在或无权限");
}
allDocs.push(doc);
if (!workspaceId) {
workspaceId = doc.workspace_id;
} else if (workspaceId !== doc.workspace_id) {
throw new Error("源页面不在同一工作空间");
}
}
if (!workspaceId) {
throw new Error("缺少目标工作空间");
}
const workspaceDocs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", workspaceId))
.collect();
const ownedDocs = workspaceDocs.filter((d) => d.user_id === userId && d.deleted_at == null);
const childrenByParent = new Map<string | null, CopyTreeDocument[]>();
for (const doc of ownedDocs as CopyTreeDocument[]) {
const list = childrenByParent.get(doc.parent_id ?? null) ?? [];
list.push(doc);
childrenByParent.set(doc.parent_id ?? null, list);
}
const existingTitleSetByParent = new Map<string | null, Set<string>>();
const seedTitleSet = (parent: string | null) => {
if (existingTitleSetByParent.has(parent)) return;
const titles = new Set<string>();
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
existingTitleSetByParent.set(parent, titles);
};
seedTitleSet(args.targetParentId);
const newIdByOldId = new Map<string, string>();
const copyQueue: Array<{ old: CopyTreeDocument; newParentId: string | null }> = [];
const enqueueTree = (root: CopyTreeDocument, newParent: string | null, recursive: boolean) => {
const visit = (node: CopyTreeDocument, parentNewId: string | null) => {
const newId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
newIdByOldId.set(node.id, newId);
copyQueue.push({ old: node, newParentId: parentNewId });
if (!recursive) return;
const children = getChildrenSorted(childrenByParent, node.id);
children.forEach((child) => visit(child, newId));
};
visit(root, newParent);
};
for (const item of normalizedItems) {
const doc = allDocs.find((d) => d.id === item.documentId) ?? null;
if (doc) {
enqueueTree(doc, args.targetParentId, Boolean(item.recursive));
}
}
if (copyQueue.length === 0) {
throw new Error("没有可复制的页面");
}
const insertedDocs: Array<{ oldId: string; newId: string; title: string }> = [];
for (const item of copyQueue) {
const newId = newIdByOldId.get(item.old.id)!;
const parentId = item.newParentId;
if (!existingTitleSetByParent.has(parentId)) {
seedTitleSet(parentId);
}
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
existingTitleSetByParent.set(parentId, titleSet);
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
await createDocumentRecord(ctx, {
id: newId,
workspaceId,
parentId,
title: newTitle,
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
content: item.old.content ?? [],
});
await copyMindmapsForDocument(ctx, item.old.id, newId);
insertedDocs.push({ oldId: item.old.id, newId, title: newTitle });
}
return {
items: insertedDocs,
};
},
});
export const listAllForCopy = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {