From 1c01ee48340c524af9223fac43f21d3a545e4583 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 29 May 2025 15:22:25 -0500 Subject: wip: refactoring tui --- internal/history/history.go | 441 -------------------------------------------- 1 file changed, 441 deletions(-) delete mode 100644 internal/history/history.go (limited to 'internal/history') diff --git a/internal/history/history.go b/internal/history/history.go deleted file mode 100644 index 12c94a391..000000000 --- a/internal/history/history.go +++ /dev/null @@ -1,441 +0,0 @@ -package history - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/sst/opencode/internal/db" - "github.com/sst/opencode/internal/pubsub" -) - -const ( - InitialVersion = "initial" -) - -type File struct { - ID string - SessionID string - Path string - Content string - Version string - CreatedAt time.Time - UpdatedAt time.Time -} - -const ( - EventFileCreated pubsub.EventType = "history_file_created" - EventFileVersionCreated pubsub.EventType = "history_file_version_created" - EventFileUpdated pubsub.EventType = "history_file_updated" - EventFileDeleted pubsub.EventType = "history_file_deleted" - EventSessionFilesDeleted pubsub.EventType = "history_session_files_deleted" -) - -type Service interface { - pubsub.Subscriber[File] - - Create(ctx context.Context, sessionID, path, content string) (File, error) - CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) - Get(ctx context.Context, id string) (File, error) - GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) - GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) - ListBySession(ctx context.Context, sessionID string) ([]File, error) - ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) - ListVersionsByPath(ctx context.Context, path string) ([]File, error) - Update(ctx context.Context, file File) (File, error) - Delete(ctx context.Context, id string) error - DeleteSessionFiles(ctx context.Context, sessionID string) error -} - -type service struct { - db *db.Queries - sqlDB *sql.DB - broker *pubsub.Broker[File] - mu sync.RWMutex -} - -var globalHistoryService *service - -func InitService(sqlDatabase *sql.DB) error { - if globalHistoryService != nil { - return fmt.Errorf("history service already initialized") - } - queries := db.New(sqlDatabase) - broker := pubsub.NewBroker[File]() - - globalHistoryService = &service{ - db: queries, - sqlDB: sqlDatabase, - broker: broker, - } - return nil -} - -func GetService() Service { - if globalHistoryService == nil { - panic("history service not initialized. Call history.InitService() first.") - } - return globalHistoryService -} - -func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) { - return s.createWithVersion(ctx, sessionID, path, content, InitialVersion, EventFileCreated) -} - -func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) { - s.mu.RLock() - files, err := s.db.ListFilesByPath(ctx, path) - s.mu.RUnlock() - - if err != nil && err != sql.ErrNoRows { - return File{}, fmt.Errorf("db.ListFilesByPath for next version: %w", err) - } - - latestVersionNumber := 0 - if len(files) > 0 { - // Sort to be absolutely sure about the latest version globally for this path - slices.SortFunc(files, func(a, b db.File) int { - if strings.HasPrefix(a.Version, "v") && strings.HasPrefix(b.Version, "v") { - vA, _ := strconv.Atoi(a.Version[1:]) - vB, _ := strconv.Atoi(b.Version[1:]) - return vB - vA // Descending to get latest first - } - if a.Version == InitialVersion && b.Version != InitialVersion { - return 1 // initial comes after vX - } - if b.Version == InitialVersion && a.Version != InitialVersion { - return -1 - } - // Compare timestamps as strings (ISO format sorts correctly) - if b.CreatedAt > a.CreatedAt { - return 1 - } else if a.CreatedAt > b.CreatedAt { - return -1 - } - return 0 // Equal timestamps - }) - - latestFile := files[0] - if strings.HasPrefix(latestFile.Version, "v") { - vNum, parseErr := strconv.Atoi(latestFile.Version[1:]) - if parseErr == nil { - latestVersionNumber = vNum - } - } - } - nextVersionStr := fmt.Sprintf("v%d", latestVersionNumber+1) - return s.createWithVersion(ctx, sessionID, path, content, nextVersionStr, EventFileVersionCreated) -} - -func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string, eventType pubsub.EventType) (File, error) { - s.mu.Lock() - defer s.mu.Unlock() - - const maxRetries = 3 - var file File - var err error - - for attempt := range maxRetries { - tx, txErr := s.sqlDB.BeginTx(ctx, nil) - if txErr != nil { - return File{}, fmt.Errorf("failed to begin transaction: %w", txErr) - } - qtx := s.db.WithTx(tx) - - dbFile, createErr := qtx.CreateFile(ctx, db.CreateFileParams{ - ID: uuid.New().String(), - SessionID: sessionID, - Path: path, - Content: content, - Version: version, - }) - - if createErr != nil { - if rbErr := tx.Rollback(); rbErr != nil { - slog.Error("Failed to rollback transaction on create error", "error", rbErr) - } - if strings.Contains(createErr.Error(), "UNIQUE constraint failed: files.path, files.session_id, files.version") { - if attempt < maxRetries-1 { - slog.Warn("Unique constraint violation for file version, retrying with incremented version", "path", path, "session", sessionID, "attempted_version", version, "attempt", attempt+1) - // Increment version string like v1, v2, v3... - if strings.HasPrefix(version, "v") { - numPart := version[1:] - num, parseErr := strconv.Atoi(numPart) - if parseErr == nil { - version = fmt.Sprintf("v%d", num+1) - continue // Retry with new version - } - } - // Fallback if version is not "vX" or parsing failed - version = fmt.Sprintf("%s-retry%d", version, attempt+1) - continue - } - } - return File{}, fmt.Errorf("db.CreateFile within transaction: %w", createErr) - } - - if commitErr := tx.Commit(); commitErr != nil { - return File{}, fmt.Errorf("failed to commit transaction: %w", commitErr) - } - - file = s.fromDBItem(dbFile) - s.broker.Publish(eventType, file) - return file, nil // Success - } - - return File{}, fmt.Errorf("failed to create file after %d retries due to version conflicts: %w", maxRetries, err) -} - -func (s *service) Get(ctx context.Context, id string) (File, error) { - s.mu.RLock() - defer s.mu.RUnlock() - dbFile, err := s.db.GetFile(ctx, id) - if err != nil { - if err == sql.ErrNoRows { - return File{}, fmt.Errorf("file with ID '%s' not found", id) - } - return File{}, fmt.Errorf("db.GetFile: %w", err) - } - return s.fromDBItem(dbFile), nil -} - -func (s *service) GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - // sqlc doesn't directly support GetyByPathAndVersionAndSession - // We list and filter. This could be optimized with a custom query if performance is an issue. - allFilesForPath, err := s.db.ListFilesByPath(ctx, path) - if err != nil { - return File{}, fmt.Errorf("db.ListFilesByPath for GetByPathAndVersion: %w", err) - } - - for _, dbFile := range allFilesForPath { - if dbFile.SessionID == sessionID && dbFile.Version == version { - return s.fromDBItem(dbFile), nil - } - } - return File{}, fmt.Errorf("file not found for session '%s', path '%s', version '%s'", sessionID, path, version) -} - -func (s *service) GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) { - s.mu.RLock() - defer s.mu.RUnlock() - // GetFileByPathAndSession in sqlc already orders by created_at DESC and takes LIMIT 1 - dbFile, err := s.db.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{ - Path: path, - SessionID: sessionID, - }) - if err != nil { - if err == sql.ErrNoRows { - return File{}, fmt.Errorf("no file found for path '%s' in session '%s'", path, sessionID) - } - return File{}, fmt.Errorf("db.GetFileByPathAndSession: %w", err) - } - return s.fromDBItem(dbFile), nil -} - -func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) { - s.mu.RLock() - defer s.mu.RUnlock() - dbFiles, err := s.db.ListFilesBySession(ctx, sessionID) // Assumes this orders by created_at ASC - if err != nil { - return nil, fmt.Errorf("db.ListFilesBySession: %w", err) - } - files := make([]File, len(dbFiles)) - for i, dbF := range dbFiles { - files[i] = s.fromDBItem(dbF) - } - return files, nil -} - -func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) { - s.mu.RLock() - defer s.mu.RUnlock() - dbFiles, err := s.db.ListLatestSessionFiles(ctx, sessionID) // Uses the specific sqlc query - if err != nil { - return nil, fmt.Errorf("db.ListLatestSessionFiles: %w", err) - } - files := make([]File, len(dbFiles)) - for i, dbF := range dbFiles { - files[i] = s.fromDBItem(dbF) - } - return files, nil -} - -func (s *service) ListVersionsByPath(ctx context.Context, path string) ([]File, error) { - s.mu.RLock() - defer s.mu.RUnlock() - dbFiles, err := s.db.ListFilesByPath(ctx, path) // sqlc query orders by created_at DESC - if err != nil { - return nil, fmt.Errorf("db.ListFilesByPath: %w", err) - } - files := make([]File, len(dbFiles)) - for i, dbF := range dbFiles { - files[i] = s.fromDBItem(dbF) - } - return files, nil -} - -func (s *service) Update(ctx context.Context, file File) (File, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if file.ID == "" { - return File{}, fmt.Errorf("cannot update file with empty ID") - } - // UpdatedAt is handled by DB trigger - dbFile, err := s.db.UpdateFile(ctx, db.UpdateFileParams{ - ID: file.ID, - Content: file.Content, - Version: file.Version, - }) - if err != nil { - return File{}, fmt.Errorf("db.UpdateFile: %w", err) - } - updatedFile := s.fromDBItem(dbFile) - s.broker.Publish(EventFileUpdated, updatedFile) - return updatedFile, nil -} - -func (s *service) Delete(ctx context.Context, id string) error { - s.mu.Lock() - fileToPublish, err := s.getServiceForPublish(ctx, id) // Use internal method with appropriate locking - s.mu.Unlock() - - if err != nil { - if strings.Contains(err.Error(), "not found") { - slog.Warn("Attempted to delete non-existent file history", "id", id) - return nil // Or return specific error if needed - } - return err - } - - s.mu.Lock() - defer s.mu.Unlock() - err = s.db.DeleteFile(ctx, id) - if err != nil { - return fmt.Errorf("db.DeleteFile: %w", err) - } - if fileToPublish != nil { - s.broker.Publish(EventFileDeleted, *fileToPublish) - } - return nil -} - -func (s *service) getServiceForPublish(ctx context.Context, id string) (*File, error) { - // Assumes outer lock is NOT held or caller manages it. - // For GetFile, it has its own RLock. - dbFile, err := s.db.GetFile(ctx, id) - if err != nil { - return nil, err - } - file := s.fromDBItem(dbFile) - return &file, nil -} - -func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error { - s.mu.Lock() // Lock for the entire operation - defer s.mu.Unlock() - - // Get files first for publishing events - filesToDelete, err := s.db.ListFilesBySession(ctx, sessionID) - if err != nil { - return fmt.Errorf("db.ListFilesBySession for deletion: %w", err) - } - - err = s.db.DeleteSessionFiles(ctx, sessionID) - if err != nil { - return fmt.Errorf("db.DeleteSessionFiles: %w", err) - } - - for _, dbFile := range filesToDelete { - file := s.fromDBItem(dbFile) - s.broker.Publish(EventFileDeleted, file) // Individual delete events - } - return nil -} - -func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[File] { - return s.broker.Subscribe(ctx) -} - -func (s *service) fromDBItem(item db.File) File { - // Parse timestamps from ISO strings - createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt) - if err != nil { - slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err) - createdAt = time.Now() // Fallback - } - - updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt) - if err != nil { - slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err) - updatedAt = time.Now() // Fallback - } - - return File{ - ID: item.ID, - SessionID: item.SessionID, - Path: item.Path, - Content: item.Content, - Version: item.Version, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } -} - -func Create(ctx context.Context, sessionID, path, content string) (File, error) { - return GetService().Create(ctx, sessionID, path, content) -} - -func CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) { - return GetService().CreateVersion(ctx, sessionID, path, content) -} - -func Get(ctx context.Context, id string) (File, error) { - return GetService().Get(ctx, id) -} - -func GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) { - return GetService().GetByPathAndVersion(ctx, sessionID, path, version) -} - -func GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) { - return GetService().GetLatestByPathAndSession(ctx, path, sessionID) -} - -func ListBySession(ctx context.Context, sessionID string) ([]File, error) { - return GetService().ListBySession(ctx, sessionID) -} - -func ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) { - return GetService().ListLatestSessionFiles(ctx, sessionID) -} - -func ListVersionsByPath(ctx context.Context, path string) ([]File, error) { - return GetService().ListVersionsByPath(ctx, path) -} - -func Update(ctx context.Context, file File) (File, error) { - return GetService().Update(ctx, file) -} - -func Delete(ctx context.Context, id string) error { - return GetService().Delete(ctx, id) -} - -func DeleteSessionFiles(ctx context.Context, sessionID string) error { - return GetService().DeleteSessionFiles(ctx, sessionID) -} - -func Subscribe(ctx context.Context) <-chan pubsub.Event[File] { - return GetService().Subscribe(ctx) -} -- cgit v1.2.3