import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { requireUserId } from "./_utils/auth"; import { nowIso } from "./_utils/time"; 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; } function sortByNewest>(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 stripDomainEventCursorPrefix(id: string) { return id.startsWith("domain_event:") ? id.slice("domain_event:".length) : id; } 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 encodeDomainEventCursor(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: `domain_event:${row.id}`, }); } function encodeOverviewNextCursor( commandLogs: Array<{ created_at?: string | null; id?: string | null }>, domainEvents: Array<{ created_at?: string | null; id?: string | null }>, ) { // overview 的分页主轴仍以 command log 为准;没有 command log 时才回退到 domain event cursor。 // 实时流的 live tail 每轮查最新窗口,不依赖这个 next_cursor。 const oldestCommand = commandLogs[commandLogs.length - 1] ?? null; const oldestEvent = domainEvents[domainEvents.length - 1] ?? null; return encodeCursor(oldestCommand) ?? encodeDomainEventCursor(oldestEvent); } function matchesCursor>( 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 ?? ""); const cursorId = stripDomainEventCursorPrefix(cursor.id); if (!createdAt || !id) return false; if (createdAt < cursor.createdAt) return true; if (createdAt > cursor.createdAt) return false; return id < cursorId; } 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; } type Cursor = { createdAt: string; id: string } | null; const OVERVIEW_SCAN_MULTIPLIER = 4; function overviewScanLimit(limit: number) { return Math.min(500, Math.max(limit + 1, limit * OVERVIEW_SCAN_MULTIPLIER)); } function applyCursorUpperBound(query: any, cursor: Cursor) { return cursor ? query.lte("created_at", cursor.createdAt) : query; } async function fetchCommandLogWindow(ctx: any, args: { workspaceId: string; limit: number; cursor: Cursor; commandStatus: string | null; targetPageId: string | null; targetBlockId: string | null; }) { const scanLimit = overviewScanLimit(args.limit); let query; if (args.targetBlockId) { query = ctx.db .query("command_logs") .withIndex("by_workspace_target_block_created_at", (q: any) => applyCursorUpperBound( q.eq("workspace_id", args.workspaceId).eq("target_block_id", args.targetBlockId), args.cursor, ), ); } else if (args.targetPageId) { query = ctx.db .query("command_logs") .withIndex("by_workspace_target_page_created_at", (q: any) => applyCursorUpperBound( q.eq("workspace_id", args.workspaceId).eq("target_page_id", args.targetPageId), args.cursor, ), ); } else if (args.commandStatus) { query = ctx.db .query("command_logs") .withIndex("by_workspace_status_created_at", (q: any) => applyCursorUpperBound( q.eq("workspace_id", args.workspaceId).eq("status", args.commandStatus), args.cursor, ), ); } else { query = ctx.db .query("command_logs") .withIndex("by_workspace_created_at", (q: any) => applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor), ); } const rows = await query.order("desc").take(scanLimit); const filteredRows = rows.filter((row: any) => { if (args.commandStatus && row.status !== args.commandStatus) return false; if (args.targetPageId && String(row.target_page_id ?? "") !== args.targetPageId) return false; if (args.targetBlockId && String(row.target_block_id ?? "") !== args.targetBlockId) return false; return matchesCursor(row, args.cursor); }); return { rows: filteredRows.slice(0, args.limit), hasMore: filteredRows.length > args.limit || rows.length === scanLimit, }; } async function fetchDomainEventWindow(ctx: any, args: { workspaceId: string; limit: number; cursor: Cursor; eventStatus: string | null; aggregateType: string | null; aggregateId: string | null; }) { const scanLimit = overviewScanLimit(args.limit); let query; if (args.aggregateType && args.aggregateId) { query = ctx.db .query("domain_events") .withIndex("by_workspace_aggregate_created_at", (q: any) => applyCursorUpperBound( q .eq("workspace_id", args.workspaceId) .eq("aggregate_type", args.aggregateType) .eq("aggregate_id", args.aggregateId), args.cursor, ), ); } else if (args.eventStatus) { query = ctx.db .query("domain_events") .withIndex("by_workspace_status_created_at", (q: any) => applyCursorUpperBound( q.eq("workspace_id", args.workspaceId).eq("status", args.eventStatus), args.cursor, ), ); } else { query = ctx.db .query("domain_events") .withIndex("by_workspace_created_at", (q: any) => applyCursorUpperBound(q.eq("workspace_id", args.workspaceId), args.cursor), ); } const rows = await query.order("desc").take(scanLimit); const filteredRows = rows.filter((row: any) => { if (args.eventStatus && row.status !== args.eventStatus) return false; if (args.aggregateType && String(row.aggregate_type ?? "") !== args.aggregateType) return false; if (args.aggregateId && String(row.aggregate_id ?? "") !== args.aggregateId) return false; return matchesCursor(row, args.cursor); }); return { rows: filteredRows.slice(0, args.limit), hasMore: filteredRows.length > args.limit || rows.length === scanLimit, }; } export const recordCommandLog = mutation({ args: { workspaceId: v.string(), id: v.string(), requestId: v.string(), traceId: v.string(), commandId: v.string(), commandName: v.string(), actorId: v.string(), actorType: v.string(), sourceChannel: v.string(), sourceClient: v.string(), status: v.union(v.literal("pending"), v.literal("succeeded"), v.literal("failed"), v.literal("rolled_back")), targetPageId: v.optional(v.union(v.string(), v.null())), targetBlockId: v.optional(v.union(v.string(), v.null())), payload: v.any(), payloadSummary: v.string(), refs: v.array(v.string()), idempotencyKey: v.optional(v.union(v.string(), v.null())), error: v.optional(v.union(v.string(), v.null())), createdAt: v.string(), finishedAt: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); await requireWorkspaceMember(ctx, args.workspaceId, userId); const existing = await ctx.db .query("command_logs") .withIndex("by_command_log_id", (q) => q.eq("id", args.id)) .first(); if (existing) { return { ok: true, id: existing.id, duplicated: true }; } await ctx.db.insert("command_logs", { id: args.id, workspace_id: args.workspaceId, request_id: args.requestId, trace_id: args.traceId, command_id: args.commandId, command_name: args.commandName, actor_id: args.actorId, actor_type: args.actorType, source_channel: args.sourceChannel, source_client: args.sourceClient, status: args.status, target_page_id: args.targetPageId ?? null, target_block_id: args.targetBlockId ?? null, payload: args.payload, payload_summary: args.payloadSummary, refs: args.refs, idempotency_key: args.idempotencyKey ?? null, error: args.error ?? null, created_at: args.createdAt, finished_at: args.finishedAt ?? null, }); return { ok: true, id: args.id, duplicated: false }; }, }); export const recordDomainEvent = mutation({ args: { workspaceId: v.string(), id: v.string(), requestId: v.string(), traceId: v.string(), commandId: v.string(), commandLogId: v.string(), eventType: v.string(), aggregateType: v.string(), aggregateId: v.string(), eventVersion: v.number(), status: v.union(v.literal("pending"), v.literal("committed"), v.literal("rejected"), v.literal("failed")), actorType: v.string(), payload: v.any(), createdAt: v.string(), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); await requireWorkspaceMember(ctx, args.workspaceId, userId); const existing = await ctx.db .query("domain_events") .withIndex("by_domain_event_id", (q) => q.eq("id", args.id)) .first(); if (existing) { return { ok: true, id: existing.id, duplicated: true }; } await ctx.db.insert("domain_events", { id: args.id, workspace_id: args.workspaceId, request_id: args.requestId, trace_id: args.traceId, command_id: args.commandId, command_log_id: args.commandLogId, event_type: args.eventType, aggregate_type: args.aggregateType, aggregate_id: args.aggregateId, event_version: args.eventVersion, status: args.status, actor_type: args.actorType, payload: args.payload, created_at: args.createdAt, }); return { ok: true, id: args.id, duplicated: false }; }, }); export const listByTrace = query({ args: { workspaceId: v.string(), traceId: 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_trace", (q) => q.eq("workspace_id", args.workspaceId).eq("trace_id", args.traceId)) .collect(); const domainEvents = await ctx.db .query("domain_events") .withIndex("by_workspace_trace", (q) => q.eq("workspace_id", args.workspaceId).eq("trace_id", args.traceId)) .collect(); return { trace_id: args.traceId, command_logs: commandLogs, domain_events: domainEvents, generated_at: nowIso(), }; }, }); export const listByRequest = query({ args: { workspaceId: v.string(), requestId: 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_request", (q) => q.eq("workspace_id", args.workspaceId).eq("request_id", args.requestId)) .collect(); const domainEvents = await ctx.db .query("domain_events") .withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId).eq("request_id", args.requestId)) .collect(); return { request_id: args.requestId, command_logs: commandLogs, domain_events: domainEvents, generated_at: nowIso(), }; }, }); 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 commandWindow = await fetchCommandLogWindow(ctx, { workspaceId: args.workspaceId, limit, cursor, commandStatus, targetPageId, targetBlockId, }); const domainEventWindow = await fetchDomainEventWindow(ctx, { workspaceId: args.workspaceId, limit, cursor, eventStatus, aggregateType, aggregateId, }); const pageCommandLogs = sortByNewest(commandWindow.rows); const domainEvents = sortByNewest(domainEventWindow.rows); const nextCursor = encodeOverviewNextCursor(pageCommandLogs, domainEvents); return { workspace_id: args.workspaceId, command_logs: pageCommandLogs, domain_events: domainEvents, next_cursor: nextCursor, has_more: commandWindow.hasMore || domainEventWindow.hasMore, filters: { command_status: commandStatus, event_status: eventStatus, target_page_id: targetPageId, target_block_id: targetBlockId, aggregate_type: aggregateType, aggregate_id: aggregateId, }, generated_at: nowIso(), }; }, });