Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
import { eq, and, asc, desc, ilike, type Db, conversations, messages } from '@mosaic/db';
|
|
|
|
/** Maximum number of conversations returned per list query. */
|
|
const MAX_CONVERSATIONS = 200;
|
|
/** Maximum number of messages returned per conversation history query. */
|
|
const MAX_MESSAGES = 500;
|
|
|
|
export type Conversation = typeof conversations.$inferSelect;
|
|
export type NewConversation = typeof conversations.$inferInsert;
|
|
export type Message = typeof messages.$inferSelect;
|
|
export type NewMessage = typeof messages.$inferInsert;
|
|
|
|
export interface MessageSearchResult {
|
|
messageId: string;
|
|
conversationId: string;
|
|
conversationTitle: string | null;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export function createConversationsRepo(db: Db) {
|
|
return {
|
|
async findAll(userId: string): Promise<Conversation[]> {
|
|
return db
|
|
.select()
|
|
.from(conversations)
|
|
.where(eq(conversations.userId, userId))
|
|
.orderBy(desc(conversations.updatedAt))
|
|
.limit(MAX_CONVERSATIONS);
|
|
},
|
|
|
|
/**
|
|
* Find a conversation by ID, scoped to the given user.
|
|
* Returns undefined if the conversation does not exist or belongs to a different user.
|
|
*/
|
|
async findById(id: string, userId: string): Promise<Conversation | undefined> {
|
|
const rows = await db
|
|
.select()
|
|
.from(conversations)
|
|
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)));
|
|
return rows[0];
|
|
},
|
|
|
|
async create(data: NewConversation): Promise<Conversation> {
|
|
const rows = await db.insert(conversations).values(data).returning();
|
|
return rows[0]!;
|
|
},
|
|
|
|
/**
|
|
* Update a conversation, scoped to the given user.
|
|
* Returns undefined if the conversation does not exist or belongs to a different user.
|
|
*/
|
|
async update(
|
|
id: string,
|
|
userId: string,
|
|
data: Partial<NewConversation>,
|
|
): Promise<Conversation | undefined> {
|
|
const rows = await db
|
|
.update(conversations)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)))
|
|
.returning();
|
|
return rows[0];
|
|
},
|
|
|
|
/**
|
|
* Delete a conversation, scoped to the given user.
|
|
* Returns false if the conversation does not exist or belongs to a different user.
|
|
*/
|
|
async remove(id: string, userId: string): Promise<boolean> {
|
|
const rows = await db
|
|
.delete(conversations)
|
|
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)))
|
|
.returning();
|
|
return rows.length > 0;
|
|
},
|
|
|
|
/**
|
|
* Find messages for a conversation, scoped to the given user.
|
|
* Returns an empty array if the conversation does not exist or belongs to a different user.
|
|
*/
|
|
async findMessages(conversationId: string, userId: string): Promise<Message[]> {
|
|
// Verify ownership of the parent conversation before returning messages.
|
|
const conv = await db
|
|
.select()
|
|
.from(conversations)
|
|
.where(and(eq(conversations.id, conversationId), eq(conversations.userId, userId)));
|
|
if (conv.length === 0) return [];
|
|
|
|
return db
|
|
.select()
|
|
.from(messages)
|
|
.where(eq(messages.conversationId, conversationId))
|
|
.orderBy(asc(messages.createdAt))
|
|
.limit(MAX_MESSAGES);
|
|
},
|
|
|
|
/**
|
|
* Search messages by content across all conversations belonging to the user.
|
|
* Uses ILIKE for case-insensitive substring matching.
|
|
*/
|
|
async searchMessages(
|
|
userId: string,
|
|
query: string,
|
|
limit: number,
|
|
offset: number,
|
|
): Promise<MessageSearchResult[]> {
|
|
const rows = await db
|
|
.select({
|
|
messageId: messages.id,
|
|
conversationId: conversations.id,
|
|
conversationTitle: conversations.title,
|
|
role: messages.role,
|
|
content: messages.content,
|
|
createdAt: messages.createdAt,
|
|
})
|
|
.from(messages)
|
|
.innerJoin(conversations, eq(messages.conversationId, conversations.id))
|
|
.where(and(eq(conversations.userId, userId), ilike(messages.content, `%${query}%`)))
|
|
.orderBy(desc(messages.createdAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return rows;
|
|
},
|
|
|
|
/**
|
|
* Add a message to a conversation, scoped to the given user.
|
|
* Verifies the parent conversation belongs to the user before inserting.
|
|
* Returns undefined if the conversation does not exist or belongs to a different user.
|
|
*/
|
|
async addMessage(data: NewMessage, userId: string): Promise<Message | undefined> {
|
|
// Verify ownership of the parent conversation before inserting the message.
|
|
const conv = await db
|
|
.select()
|
|
.from(conversations)
|
|
.where(and(eq(conversations.id, data.conversationId), eq(conversations.userId, userId)));
|
|
if (conv.length === 0) return undefined;
|
|
|
|
const rows = await db.insert(messages).values(data).returning();
|
|
return rows[0]!;
|
|
},
|
|
};
|
|
}
|
|
|
|
export type ConversationsRepo = ReturnType<typeof createConversationsRepo>;
|