package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ── Tree Operations (v0.29.0) ─────────────────────────────────────────── func (s *MessageStore) GetActiveLeaf(ctx context.Context, channelID, userID string) (*string, error) { var leafID *string err := DB.QueryRowContext(ctx, ` SELECT active_leaf_id FROM channel_cursors WHERE channel_id = ? AND user_id = ? `, channelID, userID).Scan(&leafID) if err == nil && leafID != nil { var exists bool DB.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM messages WHERE id = ? AND deleted_at IS NULL) `, *leafID).Scan(&exists) if exists { return leafID, nil } } var fallbackID string err = DB.QueryRowContext(ctx, ` SELECT id FROM messages WHERE channel_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1 `, channelID).Scan(&fallbackID) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return &fallbackID, nil } func (s *MessageStore) GetPathToLeaf(ctx context.Context, channelID, leafID string) ([]store.PathMessage, error) { rows, err := DB.QueryContext(ctx, ` WITH RECURSIVE path AS ( SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at, 0 AS depth FROM messages WHERE id = ? AND channel_id = ? AND deleted_at IS NULL UNION ALL SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, m.participant_type, m.participant_id, m.sibling_index, m.created_at, p.depth + 1 FROM messages m JOIN path p ON m.id = p.parent_id WHERE m.deleted_at IS NULL ) SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at FROM path ORDER BY depth DESC `, leafID, channelID) if err != nil { return nil, fmt.Errorf("GetPathToLeaf: %w", err) } defer rows.Close() var path []store.PathMessage for rows.Next() { var m store.PathMessage var participantType, participantID sql.NullString var toolCallsJSON, metadataJSON []byte if err := rows.Scan( &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, &toolCallsJSON, &metadataJSON, &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, ); err != nil { return nil, fmt.Errorf("GetPathToLeaf scan: %w", err) } if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { raw := json.RawMessage(toolCallsJSON) m.ToolCalls = &raw } if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { raw := json.RawMessage(metadataJSON) m.Metadata = &raw } if participantType.Valid { m.ParticipantType = participantType.String } if participantID.Valid { m.ParticipantID = participantID.String } path = append(path, m) } if err := rows.Err(); err != nil { return nil, err } for i := range path { count, _ := s.GetSiblingCount(ctx, channelID, path[i].ParentID) path[i].SiblingCount = count } _ = s.ResolveSenderInfo(ctx, path) return path, nil } func (s *MessageStore) GetActivePath(ctx context.Context, channelID, userID string) ([]store.PathMessage, error) { leafID, err := s.GetActiveLeaf(ctx, channelID, userID) if err != nil { return nil, err } if leafID == nil { return []store.PathMessage{}, nil } return s.GetPathToLeaf(ctx, channelID, *leafID) } func (s *MessageStore) GetSiblingsList(ctx context.Context, messageID string) ([]store.SiblingInfo, int, error) { var parentID *string var channelID string err := DB.QueryRowContext(ctx, ` SELECT parent_id, channel_id FROM messages WHERE id = ? AND deleted_at IS NULL `, messageID).Scan(&parentID, &channelID) if err != nil { return nil, 0, fmt.Errorf("message not found: %w", err) } var rows *sql.Rows if parentID == nil { rows, err = DB.QueryContext(ctx, ` SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at FROM messages WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY sibling_index, created_at `, channelID) } else { rows, err = DB.QueryContext(ctx, ` SELECT id, role, model, sibling_index, SUBSTR(content, 1, 80), created_at FROM messages WHERE parent_id = ? AND deleted_at IS NULL ORDER BY sibling_index, created_at `, *parentID) } if err != nil { return nil, 0, err } defer rows.Close() var siblings []store.SiblingInfo currentIdx := 0 for i := 0; rows.Next(); i++ { var si store.SiblingInfo if err := rows.Scan(&si.ID, &si.Role, &si.Model, &si.SiblingIndex, &si.Preview, &si.CreatedAt); err != nil { return nil, 0, err } if si.ID == messageID { currentIdx = i } siblings = append(siblings, si) } return siblings, currentIdx, rows.Err() } func (s *MessageStore) GetSiblingCount(ctx context.Context, channelID string, parentID *string) (int, error) { var count int var err error if parentID == nil { err = DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM messages WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL `, channelID).Scan(&count) } else { err = DB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM messages WHERE parent_id = ? AND deleted_at IS NULL `, *parentID).Scan(&count) } if err != nil || count == 0 { return 1, nil } return count, nil } func (s *MessageStore) FindLeafFromMessage(ctx context.Context, messageID string) (string, error) { var leafID string err := DB.QueryRowContext(ctx, ` WITH RECURSIVE descendants AS ( SELECT id, 0 AS depth FROM messages WHERE id = ? AND deleted_at IS NULL UNION ALL SELECT child.id, d.depth + 1 FROM messages child JOIN descendants d ON child.parent_id = d.id WHERE child.deleted_at IS NULL AND child.sibling_index = ( SELECT MIN(sibling_index) FROM messages WHERE parent_id = d.id AND deleted_at IS NULL ) ) SELECT id FROM descendants ORDER BY depth DESC LIMIT 1 `, messageID).Scan(&leafID) if err != nil { return messageID, nil } return leafID, nil } func (s *MessageStore) NextSiblingIndexForParent(ctx context.Context, channelID string, parentID *string) (int, error) { var maxIdx sql.NullInt64 var err error if parentID == nil { err = DB.QueryRowContext(ctx, ` SELECT MAX(sibling_index) FROM messages WHERE channel_id = ? AND parent_id IS NULL AND deleted_at IS NULL `, channelID).Scan(&maxIdx) } else { err = DB.QueryRowContext(ctx, ` SELECT MAX(sibling_index) FROM messages WHERE parent_id = ? AND deleted_at IS NULL `, *parentID).Scan(&maxIdx) } if err != nil || !maxIdx.Valid { return 0, nil } return int(maxIdx.Int64) + 1, nil } func (s *MessageStore) HasPersonaMessages(ctx context.Context, channelID string) (bool, error) { var id string err := DB.QueryRowContext(ctx, ` SELECT id FROM messages WHERE channel_id = ? AND role = 'assistant' AND participant_type = 'persona' LIMIT 1 `, channelID).Scan(&id) return err == nil && id != "", nil } func (s *MessageStore) CreateWithCursor(ctx context.Context, m *models.Message, cursorUserID string) error { m.ID = store.NewID() now := time.Now().UTC() m.CreatedAt = now // tool_calls: serialize for SQLite var tcVal interface{} if m.ToolCalls != nil { tcVal = ToJSON(m.ToolCalls) } _, err := DB.ExecContext(ctx, ` INSERT INTO messages (id, channel_id, role, content, model, tokens_used, tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index, created_at) VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?) `, m.ID, m.ChannelID, m.Role, m.Content, safeModelStr(m.Model), m.TokensUsed, tcVal, models.NullString(m.ParentID), m.ParticipantType, m.ParticipantID, safeStringPtr(m.ProviderConfigID), m.SiblingIndex, now.Format(timeFmt), ) if err != nil { return fmt.Errorf("CreateWithCursor insert: %w", err) } // Update cursor if cursorUserID != "" { _, _ = DB.ExecContext(ctx, ` INSERT INTO channel_cursors (id, channel_id, user_id, active_leaf_id) VALUES (?, ?, ?, ?) ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id, updated_at = datetime('now') `, store.NewID(), m.ChannelID, cursorUserID, m.ID) } // Touch channel _, _ = DB.ExecContext(ctx, `UPDATE channels SET updated_at = datetime('now') WHERE id = ?`, m.ChannelID) return nil } func (s *MessageStore) ResolveSenderInfo(ctx context.Context, path []store.PathMessage) error { userIDs := map[string]bool{} personaIDs := map[string]bool{} for _, m := range path { if m.ParticipantID == "" { continue } switch m.ParticipantType { case "user": userIDs[m.ParticipantID] = true case "persona": personaIDs[m.ParticipantID] = true } } userNames := map[string]string{} userAvatars := map[string]string{} for uid := range userIDs { var name, avatar sql.NullString _ = DB.QueryRowContext(ctx, ` SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = ? `, uid).Scan(&name, &avatar) if name.Valid { userNames[uid] = name.String } if avatar.Valid { userAvatars[uid] = avatar.String } } personaNames := map[string]string{} personaAvatars := map[string]string{} for pid := range personaIDs { var name, avatar sql.NullString _ = DB.QueryRowContext(ctx, ` SELECT name, avatar FROM personas WHERE id = ? `, pid).Scan(&name, &avatar) if name.Valid { personaNames[pid] = name.String } if avatar.Valid { personaAvatars[pid] = avatar.String } } for i := range path { pid := path[i].ParticipantID switch path[i].ParticipantType { case "user": if n, ok := userNames[pid]; ok { path[i].SenderName = &n } if a, ok := userAvatars[pid]; ok && a != "" { path[i].SenderAvatar = &a } case "persona": if n, ok := personaNames[pid]; ok { path[i].SenderName = &n } if a, ok := personaAvatars[pid]; ok && a != "" { path[i].SenderAvatar = &a } } } return nil } // ── helpers ───────────────────────────────── func safeModelStr(m string) string { if m == "" { return "" } return m } func safeStringPtr(s *string) string { if s == nil { return "" } return *s }