homelabby/internal/store/conversations.go
Mikkel Georgsen 623cff0d76 feat(06-01): add conversation and message CRUD methods with integration tests
- CreateConversation, AddMessage, GetConversation, ListConversations on *Store
- ErrNotFound sentinel for unknown conversation IDs
- Message, Conversation, ConversationSummary types
- LIMIT 100 soft guard on ListConversations (T-06-01-04)
- Integration tests cover full round-trip, invalid role, ErrNotFound, ordering
2026-04-10 07:31:12 +00:00

172 lines
4.6 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"time"
)
// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("store: not found")
// Message represents a single chat message stored in the messages table.
type Message struct {
ID string
ConversationID string
Role string
Content string
CreatedAt time.Time
}
// Conversation is a full conversation with all its messages.
type Conversation struct {
ID string
Model string
StartedAt time.Time
Messages []Message
}
// ConversationSummary is a lightweight view of a conversation used in list responses.
// It omits individual messages to reduce query cost (T-06-01-04: LIMIT 100 guard).
type ConversationSummary struct {
ID string
Model string
StartedAt time.Time
MessageCount int
}
// CreateConversation inserts a new conversation row and returns its UUID.
// All parameters are passed as $N placeholders (T-06-01-01).
func (s *Store) CreateConversation(ctx context.Context, model string) (string, error) {
var id string
err := s.pool.QueryRow(ctx,
`INSERT INTO conversations (model) VALUES ($1) RETURNING id`,
model,
).Scan(&id)
if err != nil {
return "", fmt.Errorf("store: create conversation: %w", err)
}
return id, nil
}
// AddMessage appends a message to an existing conversation.
// An invalid role value will cause a CHECK constraint violation from the DB (T-06-01-03).
func (s *Store) AddMessage(ctx context.Context, conversationID, role, content string) (string, error) {
var id string
err := s.pool.QueryRow(ctx,
`INSERT INTO messages (conversation_id, role, content) VALUES ($1, $2, $3) RETURNING id`,
conversationID, role, content,
).Scan(&id)
if err != nil {
return "", fmt.Errorf("store: add message: %w", err)
}
return id, nil
}
// GetConversation fetches a conversation and all its messages ordered by created_at ASC.
// Returns nil, ErrNotFound if the conversation ID does not exist.
func (s *Store) GetConversation(ctx context.Context, id string) (*Conversation, error) {
rows, err := s.pool.Query(ctx,
`SELECT c.id, c.started_at, c.model,
m.id, m.conversation_id, m.role, m.content, m.created_at
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
WHERE c.id = $1
ORDER BY m.created_at ASC NULLS LAST`,
id,
)
if err != nil {
return nil, fmt.Errorf("store: get conversation query: %w", err)
}
defer rows.Close()
var conv *Conversation
for rows.Next() {
var (
cID string
cStartedAt time.Time
cModel string
// Message fields — nullable because LEFT JOIN may produce NULLs
// when the conversation has no messages.
mID *string
mConversationID *string
mRole *string
mContent *string
mCreatedAt *time.Time
)
if err := rows.Scan(
&cID, &cStartedAt, &cModel,
&mID, &mConversationID, &mRole, &mContent, &mCreatedAt,
); err != nil {
return nil, fmt.Errorf("store: get conversation scan: %w", err)
}
if conv == nil {
conv = &Conversation{
ID: cID,
Model: cModel,
StartedAt: cStartedAt,
Messages: []Message{},
}
}
if mID != nil {
conv.Messages = append(conv.Messages, Message{
ID: *mID,
ConversationID: *mConversationID,
Role: *mRole,
Content: *mContent,
CreatedAt: *mCreatedAt,
})
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: get conversation rows: %w", err)
}
if conv == nil {
return nil, ErrNotFound
}
return conv, nil
}
// ListConversations returns all conversations ordered by started_at DESC.
// A soft LIMIT 100 guards against unbounded growth on a busy homelab instance (T-06-01-04).
func (s *Store) ListConversations(ctx context.Context) ([]ConversationSummary, error) {
rows, err := s.pool.Query(ctx,
`SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
GROUP BY c.id
ORDER BY c.started_at DESC
LIMIT 100`,
)
if err != nil {
return nil, fmt.Errorf("store: list conversations query: %w", err)
}
defer rows.Close()
var summaries []ConversationSummary
for rows.Next() {
var s ConversationSummary
if err := rows.Scan(&s.ID, &s.StartedAt, &s.Model, &s.MessageCount); err != nil {
return nil, fmt.Errorf("store: list conversations scan: %w", err)
}
summaries = append(summaries, s)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list conversations rows: %w", err)
}
if summaries == nil {
summaries = []ConversationSummary{}
}
return summaries, nil
}