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 }