From ebf373b70b7869243cfe8818800d7cabf65a441b Mon Sep 17 00:00:00 2001 From: ngoiyaeric <1.15367894e+08+ngoiyaeric@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:25:07 +0000 Subject: [PATCH] fix: prevent invalid UUIDs in chat message saves - Add isValidUUID() validation to lib/utils/index.ts - Replace suffix-based deduplication (-mxb7wz style) with crypto.randomUUID() in saveChat - Add normalizeMessageId() that strips invalid suffixes and falls back to fresh UUID - Add UUID validation in saveChat wrapper (lib/actions/chat.ts) before sending to DB - Add graceful fallback from bulk insert to individual inserts on DB error - Add createMessage() UUID validation and regeneration Fixes PostgreSQL error: invalid input syntax for type uuid when groupeId is reused across multiple messages (response + related + followup). --- lib/actions/chat-db.ts | 104 +++++++++++++++++++++++++++++++++++++---- lib/actions/chat.ts | 6 ++- lib/utils/index.ts | 17 +++++++ 3 files changed, 116 insertions(+), 11 deletions(-) diff --git a/lib/actions/chat-db.ts b/lib/actions/chat-db.ts index 0498d426..844ab34b 100644 --- a/lib/actions/chat-db.ts +++ b/lib/actions/chat-db.ts @@ -3,6 +3,7 @@ import { chats, messages, users } from '@/lib/db/schema'; import { eq, desc, and, sql, asc } from 'drizzle-orm'; // Added asc import { alias } from 'drizzle-orm/pg-core'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; // We'll use this to ensure user-specific actions +import { isValidUUID, generateUUID } from '@/lib/utils'; // Define types based on our schema for better type safety // These would ideally be generated by Drizzle Kit or defined in a central types location in a larger app @@ -12,6 +13,38 @@ export type User = typeof users.$inferSelect; export type NewChat = typeof chats.$inferInsert; export type NewMessage = typeof messages.$inferInsert; +/** + * Normalizes a message ID to a valid UUID. + * If the ID is already valid, returns it as-is. + * If the ID contains invalid suffixes (e.g., "-mxb7wz"), strips them. + * If the ID is null/undefined or cannot be normalized, generates a fresh UUID. + * @param id - The message ID to normalize. + * @returns A valid UUID string. + */ +function normalizeMessageId(id: string | undefined | null): string { + // If no ID provided, generate a fresh one + if (!id) { + return generateUUID(); + } + + // If already valid, return as-is + if (isValidUUID(id)) { + return id; + } + + // Attempt to strip common suffixes (e.g., "-mxb7wz", "-zg8y3u") + // These are base62-encoded random strings appended by the old deduplication logic + const stripped = id.replace(/-[a-zA-Z0-9]{4,10}$/, ''); + + if (isValidUUID(stripped)) { + return stripped; + } + + // If we can't salvage it, generate a fresh UUID + console.warn(`[chat-db] Invalid UUID detected and regenerated: "${id}"`); + return generateUUID(); +} + /** * Retrieves a specific chat by its ID, ensuring it belongs to the current user * or is public. @@ -73,9 +106,12 @@ export async function getChatsPage( /** * Saves a chat and its messages. If the chat exists, it updates it. - * This function should handle both creating new chats and appending messages. - * The PR implies complex logic for saving, including message IDs. - * This is a simplified version; PR #533 might have more granular message saving. + * This function handles both creating new chats and appending messages. + * + * All message IDs are guaranteed to be valid UUIDs. If a message has a + * duplicate ID within the same batch, a fresh UUID is generated for the + * duplicate — no suffix appending is used. + * * @param chatData - The chat data to save. * @param messagesData - An array of messages to save with the chat. * @returns The saved chat ID. @@ -90,6 +126,13 @@ export async function saveChat(chatData: NewChat, messagesData: Omit { let chatId = chatData.id; + // Validate the chat ID if provided + if (chatId && !isValidUUID(chatId)) { + console.warn(`[chat-db] Invalid chat ID detected: "${chatId}". Chat may not have a valid UUID.`); + // Let the DB default handle it — do not override with a new ID, + // because the chat may already exist with that ID. + } + if (chatId) { // If chat ID is provided, assume update or append messages const existingChat = await tx.select({ id: chats.id }).from(chats).where(eq(chats.id, chatId)).limit(1); if (!existingChat.length) { @@ -108,26 +151,36 @@ export async function saveChat(chatData: NewChat, messagesData: Omit 0) { const seenIds = new Set(); const messagesToInsert: typeof messages.$inferInsert[] = []; + const invalidIds: string[] = []; for (const msg of messagesData) { - let id = msg.id ?? crypto.randomUUID(); + // Normalize the ID: strip invalid suffixes or generate a fresh UUID + const normalizedId = normalizeMessageId(msg.id); // If we've already seen this ID in this batch, generate a unique one - while (seenIds.has(id)) { - id = `${id}-${Math.random().toString(36).substring(2, 8)}`; + // using generateUUID() — NOT suffix appending. + let id = normalizedId; + if (seenIds.has(id)) { + id = generateUUID(); } seenIds.add(id); + // Validate the final ID before insertion + if (!isValidUUID(id)) { + invalidIds.push(id); + console.error(`[chat-db] Generated ID is still invalid after normalization: "${id}". Skipping message.`); + continue; // Skip this message to avoid a DB error + } + messagesToInsert.push({ ...msg, id, @@ -136,7 +189,33 @@ export async function saveChat(chatData: NewChat, messagesData: Omit 0) { + console.error(`[chat-db] Skipped ${invalidIds.length} message(s) with invalid UUIDs:`, invalidIds); + } + + if (messagesToInsert.length > 0) { + try { + await tx.insert(messages).values(messagesToInsert).onConflictDoUpdate({ target: messages.id, set: { content: sql`EXCLUDED.content`, role: sql`EXCLUDED.role` } }); + } catch (dbError) { + // If the bulk insert fails (e.g., due to a constraint violation), + // fall back to inserting messages one-by-one with individual error handling. + // Wrap each retry in a nested transaction (savepoint) so one failure doesn't + // abort the entire parent transaction. + console.error('[chat-db] Bulk insert failed, falling back to individual inserts:', dbError); + + for (const msg of messagesToInsert) { + try { + await tx.transaction(async (nestedTx: any) => { + await nestedTx.insert(messages).values(msg).onConflictDoUpdate({ target: messages.id, set: { content: sql`EXCLUDED.content`, role: sql`EXCLUDED.role` } }); + }); + } catch (singleError) { + console.error(`[chat-db] Failed to save individual message (id: ${msg.id}):`, singleError); + // Continue processing remaining messages — don't break the loop + } + } + } + } } return chatId; }); @@ -155,6 +234,13 @@ export async function createMessage(messageData: NewMessage): Promise[] = chat.messages.map(msg => ({ - id: msg.id, + // Ensure every message has a valid UUID before sending to DB + id: msg.id && isValidUUID(msg.id) ? msg.id : generateUUID(), userId: effectiveUserId, role: msg.role, content: typeof msg.content === 'object' ? JSON.stringify(msg.content) : msg.content, diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 0d78dbc3..479b7406 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -21,6 +21,23 @@ export function generateUUID(): string { */ export { generateUUID as nanoid }; +/** + * UUID validation regex for v4 format (RFC 4122). + * Enforces version 4 and the RFC variant bits. + * Supports standard (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) and + * optional brace-wrapped ({xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx}) formats. + */ +const UUID_V4_REGEX = /^\{?[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\}?$/i; + +/** + * Validates whether a string is a valid UUID v4 format. + * @param id - The string to validate. + * @returns true if the string matches the UUID v4 format. + */ +export function isValidUUID(id: string): boolean { + return UUID_V4_REGEX.test(id); +} + export async function getModel(requireVision: boolean = false) { const selectedModel = await getSelectedModel();