- 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
172 lines
4.6 KiB
Go
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
|
|
}
|