import { and, desc, eq, isNull } from "drizzle-orm"; import type { CapabilityDb } from "../capabilities/context"; import { chatConversations, chatMessages } from "../db/schema"; /** * Persistent AI-chat conversation store (OPL-3076, Option A). * * Framework-neutral: messages are stored and returned as opaque JSON values * (the AI SDK's `UIMessage` shape at rest), so this module never imports the * `ai` package and runs unchanged over Turso (hub) and bun:sqlite (desktop) * through {@link CapabilityDb}. * * Ownership model: a conversation belongs to exactly ONE user in exactly one * workspace context (`organizationId`; null on the single-tenant desktop). * Every accessor takes the owner and filters by it — there is no cross-user * or org-wide read path by design (per-user history, per OPL-3076). */ export interface ChatOwner { userId: string; /** Active workspace on the hub; null in local desktop mode. */ organizationId: string | null; } /** Minimal shape this store relies on; the full UIMessage rides along as-is. */ export interface StoredChatMessage { id: string; role: string; parts?: unknown; [key: string]: unknown; } export interface ChatConversationSummary { id: string; title: string | null; createdAt: Date | null; updatedAt: Date | null; } const TITLE_MAX = 80; function ownerCondition(owner: ChatOwner) { return and( eq(chatConversations.userId, owner.userId), owner.organizationId === null ? isNull(chatConversations.organizationId) : eq(chatConversations.organizationId, owner.organizationId), ); } /** First user message's text, trimmed for the sidebar. Null when unknown. */ export function deriveConversationTitle( messages: readonly StoredChatMessage[], ): string | null { const firstUser = messages.find((m) => m.role === "user"); if (!firstUser) return null; const parts = Array.isArray(firstUser.parts) ? firstUser.parts : []; const text = parts .map((part) => typeof (part as { text?: unknown })?.text === "string" ? ((part as { text: string }).text as string) : "", ) .join(" ") .replace(/\s+/g, " ") .trim(); if (!text) return null; return text.length > TITLE_MAX ? `${text.slice(0, TITLE_MAX - 1)}…` : text; } /** Newest-first conversation list for the history UI. */ export async function listChatConversations( db: CapabilityDb, owner: ChatOwner, limit = 50, ): Promise { return db .select({ id: chatConversations.id, title: chatConversations.title, createdAt: chatConversations.createdAt, updatedAt: chatConversations.updatedAt, }) .from(chatConversations) .where(ownerCondition(owner)) .orderBy(desc(chatConversations.updatedAt)) .limit(limit); } /** Ownership-checked single conversation, or null. */ export async function getChatConversation( db: CapabilityDb, owner: ChatOwner, conversationId: string, ): Promise { const rows = await db .select({ id: chatConversations.id, title: chatConversations.title, createdAt: chatConversations.createdAt, updatedAt: chatConversations.updatedAt, }) .from(chatConversations) .where(and(eq(chatConversations.id, conversationId), ownerCondition(owner))) .limit(1); return rows[0] ?? null; } /** * Ordered messages of an owned conversation. Returns null (not `[]`) when the * conversation doesn't exist or belongs to someone else, so callers can 404 * instead of silently starting an empty thread under a hijacked id. */ export async function loadChatMessages( db: CapabilityDb, owner: ChatOwner, conversationId: string, ): Promise { const conversation = await getChatConversation(db, owner, conversationId); if (!conversation) return null; const rows = await db .select({ message: chatMessages.message }) .from(chatMessages) .where(eq(chatMessages.conversationId, conversationId)) .orderBy(chatMessages.position); return rows.map((row) => row.message as StoredChatMessage); } /** * Persist the complete message list of a conversation (create-or-replace). * * Called from the chat route's stream-end hook with the full updated * UIMessage[] — rewriting the whole conversation keeps positions dense and * makes the operation idempotent (a retried save converges instead of * duplicating). Creates the conversation row on first save; refuses to touch * a conversation owned by someone else (returns false — the caller treats * that as a client error, never a silent overwrite). */ export async function saveChatConversation( db: CapabilityDb, owner: ChatOwner, input: { conversationId: string; messages: readonly StoredChatMessage[]; /** Optional explicit title; otherwise derived on first save. */ title?: string | null; }, ): Promise { const { conversationId, messages } = input; const existing = await db .select({ id: chatConversations.id, userId: chatConversations.userId, organizationId: chatConversations.organizationId, }) .from(chatConversations) .where(eq(chatConversations.id, conversationId)) .limit(1); const now = new Date(); if (existing.length === 0) { await db.insert(chatConversations).values({ id: conversationId, userId: owner.userId, organizationId: owner.organizationId, title: input.title ?? deriveConversationTitle(messages), createdAt: now, updatedAt: now, }); } else { const row = existing[0]; const sameOwner = row.userId === owner.userId && (row.organizationId ?? null) === owner.organizationId; if (!sameOwner) return false; await db .update(chatConversations) .set({ updatedAt: now, ...(input.title !== undefined ? { title: input.title } : {}), }) .where(eq(chatConversations.id, conversationId)); } // Replace-all message write. Delete-then-insert (not diffing) so a // conversation edited by regenerate/retry converges to exactly what the // client shows. await db .delete(chatMessages) .where(eq(chatMessages.conversationId, conversationId)); if (messages.length > 0) { await db.insert(chatMessages).values( messages.map((message, position) => ({ conversationId, position, message, createdAt: now, })), ); } return true; } /** Rename an owned conversation. False when not found / not the owner. */ export async function renameChatConversation( db: CapabilityDb, owner: ChatOwner, conversationId: string, title: string, ): Promise { const conversation = await getChatConversation(db, owner, conversationId); if (!conversation) return false; await db .update(chatConversations) .set({ title: title.trim().slice(0, TITLE_MAX) || null }) .where(eq(chatConversations.id, conversationId)); return true; } /** * Delete an owned conversation and its messages. Messages are deleted * explicitly rather than relying on FK cascade — SQLite only enforces * cascades when foreign_keys is enabled on the connection, which differs * between drivers. False when not found / not the owner. */ export async function deleteChatConversation( db: CapabilityDb, owner: ChatOwner, conversationId: string, ): Promise { const conversation = await getChatConversation(db, owner, conversationId); if (!conversation) return false; await db .delete(chatMessages) .where(eq(chatMessages.conversationId, conversationId)); await db .delete(chatConversations) .where(eq(chatConversations.id, conversationId)); return true; }