diff --git a/.infer/.gitignore b/.infer/.gitignore index 26c1cd1f..e903057a 100644 --- a/.infer/.gitignore +++ b/.infer/.gitignore @@ -2,7 +2,7 @@ logs/*.log history chat_export_* -conversations.db +conversations.db* conversations bin/ tmp/ diff --git a/cmd/init.go b/cmd/init.go index 1b485131..40b8245f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -30,12 +30,14 @@ This is the recommended command to start working with Inference Gateway CLI in a func init() { initCmd.Flags().Bool("overwrite", false, "Overwrite existing files if they already exist") initCmd.Flags().Bool("userspace", false, "Initialize configuration in user home directory (~/.infer/)") + initCmd.Flags().Bool("skip-migrations", false, "Skip running database migrations") rootCmd.AddCommand(initCmd) } func initializeProject(cmd *cobra.Command) error { overwrite, _ := cmd.Flags().GetBool("overwrite") userspace, _ := cmd.Flags().GetBool("userspace") + skipMigrations, _ := cmd.Flags().GetBool("skip-migrations") var configPath, gitignorePath, scmShortcutsPath, gitShortcutsPath, mcpShortcutsPath, shellsShortcutsPath, exportShortcutsPath, a2aShortcutsPath, mcpPath string @@ -79,7 +81,7 @@ func initializeProject(cmd *cobra.Command) error { logs/*.log history chat_export_* -conversations.db +conversations.db* conversations bin/ tmp/ @@ -148,6 +150,17 @@ tmp/ fmt.Println("") fmt.Println("Tip: Use /init in chat mode to generate an AGENTS.md file interactively") + if !skipMigrations { + fmt.Println("") + fmt.Println("Running database migrations...") + if err := runMigrations(); err != nil { + fmt.Printf("%s Warning: Failed to run migrations: %v\n", icons.CrossMarkStyle.Render(icons.CrossMark), err) + fmt.Println(" You can run migrations manually with: infer migrate") + } else { + fmt.Printf("%s Database migrations completed successfully\n", icons.CheckMarkStyle.Render(icons.CheckMark)) + } + } + return nil } diff --git a/cmd/init_test.go b/cmd/init_test.go index a9d7e0e3..07b9d7bd 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -20,8 +20,9 @@ func TestInitializeProject(t *testing.T) { { name: "basic project initialization", flags: map[string]any{ - "overwrite": false, - "userspace": false, + "overwrite": false, + "userspace": false, + "skip-migrations": true, }, wantFiles: []string{".infer/config.yaml", ".infer/.gitignore"}, wantNoFiles: []string{"AGENTS.md"}, @@ -30,8 +31,9 @@ func TestInitializeProject(t *testing.T) { { name: "userspace initialization", flags: map[string]any{ - "overwrite": true, - "userspace": true, + "overwrite": true, + "userspace": true, + "skip-migrations": true, }, wantFiles: []string{}, wantNoFiles: []string{".infer/config.yaml", ".infer/.gitignore", "AGENTS.md"}, diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 00000000..c4d00f0e --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + container "github.com/inference-gateway/cli/internal/container" + storage "github.com/inference-gateway/cli/internal/infra/storage" + migrations "github.com/inference-gateway/cli/internal/infra/storage/migrations" + icons "github.com/inference-gateway/cli/internal/ui/styles/icons" + cobra "github.com/spf13/cobra" +) + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Run database migrations", + Long: `Run database migrations to update the schema to the latest version. + +This command applies any pending migrations to your database. Migrations are tracked +in the schema_migrations table to ensure they are only applied once. + +The command automatically detects your database backend (SQLite, PostgreSQL, JSONL, Redis, Memory) +and applies the appropriate migrations. Note that JSONL, Redis, and Memory storage backends +do not require migrations as they do not use a relational schema.`, + RunE: func(cmd *cobra.Command, args []string) error { + status, _ := cmd.Flags().GetBool("status") + if status { + return showMigrationStatus() + } + return runMigrations() + }, +} + +func init() { + migrateCmd.Flags().Bool("status", false, "Show migration status without applying migrations") + rootCmd.AddCommand(migrateCmd) +} + +// runMigrations executes pending database migrations +func runMigrations() error { + cfg, err := getConfigFromViper() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + serviceContainer := container.NewServiceContainer(cfg, V) + + conversationStorage := serviceContainer.GetStorage() + + defer func() { + if err := conversationStorage.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close storage: %v\n", err) + } + }() + + switch conversationStorage.(type) { + case *storage.SQLiteStorage: + fmt.Printf("%s SQLite database migrations are up to date\n", icons.CheckMarkStyle.Render(icons.CheckMark)) + fmt.Println(" All migrations have been applied automatically") + return nil + case *storage.PostgresStorage: + fmt.Printf("%s PostgreSQL database migrations are up to date\n", icons.CheckMarkStyle.Render(icons.CheckMark)) + fmt.Println(" All migrations have been applied automatically") + return nil + case *storage.JsonlStorage: + fmt.Println("JSONL storage does not require migrations") + return nil + case *storage.MemoryStorage: + fmt.Println("Memory storage does not require migrations") + return nil + case *storage.RedisStorage: + fmt.Println("Redis storage does not require migrations") + return nil + default: + return fmt.Errorf("unsupported storage backend: %T", conversationStorage) + } +} + +// showMigrationStatus displays the current migration status +func showMigrationStatus() error { + cfg, err := getConfigFromViper() + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + serviceContainer := container.NewServiceContainer(cfg, V) + + conversationStorage := serviceContainer.GetStorage() + + defer func() { + if err := conversationStorage.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close storage: %v\n", err) + } + }() + + switch s := conversationStorage.(type) { + case *storage.SQLiteStorage: + return showSQLiteMigrationStatus(s) + case *storage.PostgresStorage: + return showPostgresMigrationStatus(s) + case *storage.JsonlStorage: + fmt.Println("JSONL storage does not require migrations") + return nil + case *storage.MemoryStorage: + fmt.Println("Memory storage does not require migrations") + return nil + case *storage.RedisStorage: + fmt.Println("Redis storage does not require migrations") + return nil + default: + return fmt.Errorf("unsupported storage backend: %T", s) + } +} + +// showSQLiteMigrationStatus shows migration status for SQLite +func showSQLiteMigrationStatus(s *storage.SQLiteStorage) error { + ctx := context.Background() + db := s.DB() + if db == nil { + return fmt.Errorf("database connection is nil") + } + + runner := migrations.NewMigrationRunner(db, "sqlite") + allMigrations := migrations.GetSQLiteMigrations() + + status, err := runner.GetMigrationStatus(ctx, allMigrations) + if err != nil { + return fmt.Errorf("failed to get migration status: %w", err) + } + + fmt.Println("SQLite Migration Status:") + fmt.Println() + for _, s := range status { + statusIcon := icons.StyledCrossMark() + statusText := "Pending" + if s.Applied { + statusIcon = icons.StyledCheckMark() + statusText = "Applied" + } + fmt.Printf(" %s Version %s: %s (%s)\n", statusIcon, s.Version, s.Description, statusText) + } + + return nil +} + +// showPostgresMigrationStatus shows migration status for PostgreSQL +func showPostgresMigrationStatus(s *storage.PostgresStorage) error { + ctx := context.Background() + db := s.DB() + if db == nil { + return fmt.Errorf("database connection is nil") + } + + runner := migrations.NewMigrationRunner(db, "postgres") + allMigrations := migrations.GetPostgresMigrations() + + status, err := runner.GetMigrationStatus(ctx, allMigrations) + if err != nil { + return fmt.Errorf("failed to get migration status: %w", err) + } + + fmt.Println("PostgreSQL Migration Status:") + fmt.Println() + for _, s := range status { + statusIcon := icons.StyledCrossMark() + statusText := "Pending" + if s.Applied { + statusIcon = icons.StyledCheckMark() + statusText = "Applied" + } + fmt.Printf(" %s Version %s: %s (%s)\n", statusIcon, s.Version, s.Description, statusText) + } + + return nil +} diff --git a/docs/database-migrations.md b/docs/database-migrations.md new file mode 100644 index 00000000..06437b79 --- /dev/null +++ b/docs/database-migrations.md @@ -0,0 +1,260 @@ +# Database Migrations + +This document describes the database migration system in the Inference Gateway CLI. + +## Overview + +The CLI uses a migration system to ensure smooth upgrades between versions. Migrations are automatically applied when the +database is initialized, ensuring your schema is always up to date. + +## How It Works + +### Migration System + +The migration system tracks which migrations have been applied using a `schema_migrations` table. Each migration has: + +- **Version**: A unique identifier (e.g., "001", "002") +- **Description**: Human-readable description of what the migration does +- **UpSQL**: SQL statements to apply the migration +- **DownSQL**: SQL statements to rollback the migration (optional) + +### Automatic Migrations + +Migrations are **automatically applied** in the following scenarios: + +1. **First time initialization**: When you run `infer init` +2. **Database connection**: When the CLI connects to the database for the first time +3. **Version upgrades**: When upgrading to a new CLI version with schema changes + +### Migration Tracking + +The `schema_migrations` table tracks applied migrations: + +```sql +CREATE TABLE schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + description TEXT NOT NULL, + applied_at TIMESTAMP NOT NULL +); +``` + +## Supported Databases + +The migration system supports: + +- **SQLite**: Default storage backend +- **PostgreSQL**: Production-ready relational database +- **Redis**: In-memory storage (no migrations needed) +- **Memory**: In-memory storage for testing (no migrations needed) + +## Commands + +### Run Migrations + +Migrations are automatically run when connecting to the database. To manually trigger migrations: + +```bash +# Run pending migrations +infer migrate + +# Show migration status without applying +infer migrate --status +``` + +### Initialize with Migrations + +When initializing a new project, migrations are run automatically: + +```bash +# Initialize and run migrations (default) +infer init --overwrite + +# Initialize without running migrations +infer init --overwrite --skip-migrations +``` + +### View Migration Status + +Check which migrations have been applied: + +```bash +infer migrate --status +``` + +Output example: + +```text +SQLite Migration Status: + + ✅ Version 001: Initial schema - conversations table (Applied) + ❌ Version 002: Add user preferences table (Pending) +``` + +## Upgrading Between Versions + +### Automatic Upgrade Path + +When upgrading the CLI to a newer version: + +1. **Stop the CLI**: Close any running chat sessions +2. **Upgrade the binary**: Install the new version +3. **Run the CLI**: Migrations apply automatically on first use +4. **Verify**: Check status with `infer migrate --status` + +### Manual Migration + +If you prefer to run migrations manually: + +```bash +# After upgrading, run migrations explicitly +infer migrate + +# Verify all migrations applied +infer migrate --status +``` + +### Rollback Strategy + +The migration system does not support automatic rollback. If a migration fails: + +1. The transaction is rolled back automatically +2. No partial state is left in the database +3. The migration must be fixed before proceeding +4. Downgrade the CLI if needed and restore from backup + +## Adding New Migrations + +### For Developers + +When adding schema changes: + +1. **Create migration file**: + - SQLite: `internal/infra/storage/migrations/sqlite_migrations.go` + - PostgreSQL: `internal/infra/storage/migrations/postgres_migrations.go` + +2. **Add migration to the list**: + +```go +// SQLite example +{ + Version: "002", + Description: "Add user preferences table", + UpSQL: ` + CREATE TABLE user_preferences ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + settings TEXT NOT NULL + ); + CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id); + `, + DownSQL: ` + DROP INDEX IF EXISTS idx_user_preferences_user_id; + DROP TABLE IF EXISTS user_preferences; + `, +} +``` + +3. **Test the migration**: + +```bash +# Run tests +go test ./internal/infra/storage/migrations/... + +# Test manually with a fresh database +rm ~/.infer/conversations.db +infer chat +``` + +4. **Document the change**: Update this file and CHANGELOG.md + +### Migration Best Practices + +1. **Incremental versions**: Use sequential numbers (001, 002, 003) +2. **Idempotent SQL**: Use `IF NOT EXISTS` and `IF EXISTS` clauses +3. **Transactional**: Keep migrations atomic (all or nothing) +4. **Backwards compatible**: Avoid breaking changes when possible +5. **Test thoroughly**: Test on fresh databases and with existing data +6. **Document schema changes**: Update docs and CHANGELOG + +## Troubleshooting + +### Migration Failed + +If a migration fails: + +```bash +# Check migration status +infer migrate --status + +# Review error message +infer migrate +``` + +Common issues: + +- **Database locked**: Close all CLI instances +- **Permission denied**: Check file/directory permissions +- **Syntax error**: Review migration SQL +- **Constraint violation**: Check for existing data conflicts + +### Reset Database + +To start fresh (⚠️ **destroys all data**): + +```bash +# SQLite (default) +rm ~/.infer/conversations.db +infer migrate + +# PostgreSQL +psql -c "DROP DATABASE infer_gateway; CREATE DATABASE infer_gateway;" +infer migrate +``` + +### Manual Intervention + +If automatic migration fails, you can apply migrations manually: + +```bash +# SQLite +sqlite3 ~/.infer/conversations.db < migration.sql + +# PostgreSQL +psql infer_gateway < migration.sql +``` + +## Schema Versioning + +The current schema version can be determined by: + +```bash +# Show applied migrations +infer migrate --status + +# Query directly (SQLite) +sqlite3 ~/.infer/conversations.db "SELECT version, description, applied_at FROM schema_migrations ORDER BY version;" + +# Query directly (PostgreSQL) +psql infer_gateway -c "SELECT version, description, applied_at FROM schema_migrations ORDER BY version;" +``` + +## Migration History + +### Version 001 (Initial Schema) + +**SQLite**: + +- Created `conversations` table with all required columns +- Added index on `updated_at` for efficient queries + +**PostgreSQL**: + +- Created `conversations` table with JSONB columns +- Created `conversation_entries` table with foreign key +- Added indexes for efficient queries + +## See Also + +- [Storage Architecture](./storage-architecture.md) (if exists) +- [Configuration Guide](../CLAUDE.md) +- [Upgrade Guide](../CHANGELOG.md) diff --git a/internal/app/chat.go b/internal/app/chat.go index 6df8ccad..187362f3 100644 --- a/internal/app/chat.go +++ b/internal/app/chat.go @@ -1690,6 +1690,33 @@ func (app *ChatApplication) handleEditReady(event domain.MessageHistoryEditReady EditTimestamp: time.Now(), }) + entries := app.conversationRepo.GetMessages() + deleteIndex := app.adjustRestoreIndexForEdit(entries, event.MessageIndex) + + var err error + if deleteIndex == 0 { + err = app.conversationRepo.Clear() + } else { + err = app.conversationRepo.DeleteMessagesAfterIndex(deleteIndex - 1) + } + + if err != nil { + logger.Error("Failed to delete messages during edit", "error", err) + cmds = append(cmds, func() tea.Msg { + return domain.ShowErrorEvent{ + Error: fmt.Sprintf("Failed to delete messages: %v", err), + Sticky: true, + } + }) + return cmds + } + + cmds = append(cmds, func() tea.Msg { + return domain.UpdateHistoryEvent{ + History: app.conversationRepo.GetMessages(), + } + }) + if iv, ok := app.inputView.(*components.InputView); ok { iv.SetText(event.Content) iv.SetCursor(len(event.Content)) @@ -1702,6 +1729,33 @@ func (app *ChatApplication) handleEditReady(event domain.MessageHistoryEditReady return cmds } +// adjustRestoreIndexForEdit adjusts the restore index based on message role and tool calls +// This is similar to the logic in message_history_handler.go but adapted for the app layer +func (app *ChatApplication) adjustRestoreIndexForEdit(entries []domain.ConversationEntry, restoreIndex int) int { + if restoreIndex >= len(entries) { + return restoreIndex + } + + msg := entries[restoreIndex] + if msg.Message.Role == sdk.Assistant && msg.Message.ToolCalls != nil && len(*msg.Message.ToolCalls) > 0 { + toolResponsesFound := 0 + for i := restoreIndex + 1; i < len(entries); i++ { + if entries[i].Message.Role == sdk.Tool { + restoreIndex = i + toolResponsesFound++ + } else { + break + } + } + } else { + for restoreIndex > 0 && entries[restoreIndex].Message.Role == sdk.Tool { + restoreIndex-- + } + } + + return restoreIndex +} + // handleMessageHistoryEnter handles the enter key press in message history mode func (app *ChatApplication) handleMessageHistoryEnter(cv *components.ConversationView, iv *components.InputView, cmds []tea.Cmd) []tea.Cmd { selectedIndex := cv.GetSelectedMessageIndex() diff --git a/internal/handlers/chat_handler.go b/internal/handlers/chat_handler.go index d0cb9269..47974fd9 100644 --- a/internal/handlers/chat_handler.go +++ b/internal/handlers/chat_handler.go @@ -793,6 +793,8 @@ func isUIOnlyEvent(msg tea.Msg) bool { domain.AutocompleteHideEvent, domain.AutocompleteCompleteEvent, domain.MessageHistoryReadyEvent, + domain.MessageHistoryEditReadyEvent, + domain.MessageEditSubmitEvent, tea.KeyMsg, tea.WindowSizeMsg, tea.MouseMsg, diff --git a/internal/handlers/message_history_handler.go b/internal/handlers/message_history_handler.go index d9ab3f64..cce482f7 100644 --- a/internal/handlers/message_history_handler.go +++ b/internal/handlers/message_history_handler.go @@ -100,18 +100,6 @@ func (h *MessageHistoryHandler) HandleEdit(event domain.MessageHistoryEditEvent) // HandleEditSubmit processes the message edit submission func (h *MessageHistoryHandler) HandleEditSubmit(event domain.MessageEditSubmitEvent) tea.Cmd { return func() tea.Msg { - entries := h.conversationRepo.GetMessages() - deleteIndex := h.adjustRestoreIndex(entries, event.OriginalIndex) - - if err := h.conversationRepo.DeleteMessagesAfterIndex(deleteIndex - 1); err != nil { - logger.Error("Failed to delete messages during edit", "error", err) - return domain.ChatErrorEvent{ - RequestID: event.RequestID, - Error: err, - Timestamp: time.Now(), - } - } - return domain.UserInputEvent{ Content: event.EditedContent, Images: event.Images, diff --git a/internal/handlers/message_history_handler_test.go b/internal/handlers/message_history_handler_test.go new file mode 100644 index 00000000..4122e09e --- /dev/null +++ b/internal/handlers/message_history_handler_test.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "testing" + "time" + + domain "github.com/inference-gateway/cli/internal/domain" + services "github.com/inference-gateway/cli/internal/services" + mocks "github.com/inference-gateway/cli/tests/mocks/domain" + sdk "github.com/inference-gateway/sdk" +) + +func TestMessageHistoryHandler_HandleEditSubmit_FirstMessage(t *testing.T) { + repo := services.NewInMemoryConversationRepository(nil, nil) + stateManager := &mocks.FakeStateManager{} + handler := NewMessageHistoryHandler(stateManager, repo) + + messages := []domain.ConversationEntry{ + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.User, Content: sdk.NewMessageContent("First user message")}, + }, + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.Assistant, Content: sdk.NewMessageContent("First assistant response")}, + }, + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.User, Content: sdk.NewMessageContent("Second user message")}, + }, + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.Assistant, Content: sdk.NewMessageContent("Second assistant response")}, + }, + } + + for _, msg := range messages { + if err := repo.AddMessage(msg); err != nil { + t.Fatalf("Failed to add message: %v", err) + } + } + + event := domain.MessageEditSubmitEvent{ + RequestID: "test-request", + OriginalIndex: 0, + EditedContent: "Edited first message", + Images: nil, + } + + cmd := handler.HandleEditSubmit(event) + msg := cmd() + + userInputEvent, ok := msg.(domain.UserInputEvent) + if !ok { + t.Fatalf("Expected UserInputEvent but got: %T", msg) + } + + if userInputEvent.Content != "Edited first message" { + t.Errorf("Expected content 'Edited first message', got '%s'", userInputEvent.Content) + } + + remainingMessages := repo.GetMessages() + if len(remainingMessages) != 4 { + t.Errorf("Expected 4 messages (deletion happens in app layer), got %d", len(remainingMessages)) + } +} + +func TestMessageHistoryHandler_HandleEditSubmit_MiddleMessage(t *testing.T) { + repo := services.NewInMemoryConversationRepository(nil, nil) + stateManager := &mocks.FakeStateManager{} + handler := NewMessageHistoryHandler(stateManager, repo) + + messages := []domain.ConversationEntry{ + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.User, Content: sdk.NewMessageContent("First")}, + }, + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.Assistant, Content: sdk.NewMessageContent("Response 1")}, + }, + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.User, Content: sdk.NewMessageContent("Second")}, + }, + { + Time: time.Now(), + Message: sdk.Message{Role: sdk.Assistant, Content: sdk.NewMessageContent("Response 2")}, + }, + } + + for _, msg := range messages { + if err := repo.AddMessage(msg); err != nil { + t.Fatalf("Failed to add message: %v", err) + } + } + + event := domain.MessageEditSubmitEvent{ + RequestID: "test-request", + OriginalIndex: 2, + EditedContent: "Edited second message", + Images: nil, + } + + cmd := handler.HandleEditSubmit(event) + msg := cmd() + + userInputEvent, ok := msg.(domain.UserInputEvent) + if !ok { + t.Fatalf("Expected UserInputEvent but got: %T", msg) + } + + if userInputEvent.Content != "Edited second message" { + t.Errorf("Expected content 'Edited second message', got '%s'", userInputEvent.Content) + } + + remainingMessages := repo.GetMessages() + expectedCount := 4 + if len(remainingMessages) != expectedCount { + t.Errorf("Expected %d messages (deletion happens in app layer), got %d", expectedCount, len(remainingMessages)) + } +} diff --git a/internal/infra/storage/migrations/migration.go b/internal/infra/storage/migrations/migration.go new file mode 100644 index 00000000..adba8cfc --- /dev/null +++ b/internal/infra/storage/migrations/migration.go @@ -0,0 +1,198 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + "sort" + "time" +) + +// Migration represents a database migration +type Migration struct { + // Version is the migration version (e.g., "001", "002") + Version string + // Description is a human-readable description of the migration + Description string + // UpSQL contains the SQL statements to apply the migration + UpSQL string + // DownSQL contains the SQL statements to rollback the migration (optional) + DownSQL string +} + +// MigrationRunner manages database migrations +type MigrationRunner struct { + db *sql.DB + dialect string // "sqlite" or "postgres" +} + +// NewMigrationRunner creates a new migration runner +func NewMigrationRunner(db *sql.DB, dialect string) *MigrationRunner { + return &MigrationRunner{ + db: db, + dialect: dialect, + } +} + +// EnsureMigrationTable creates the migration tracking table if it doesn't exist +func (r *MigrationRunner) EnsureMigrationTable(ctx context.Context) error { + var createSQL string + + switch r.dialect { + case "sqlite": + createSQL = ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + description TEXT NOT NULL, + applied_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_schema_migrations_version ON schema_migrations(version); + ` + case "postgres": + createSQL = ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + description TEXT NOT NULL, + applied_at TIMESTAMP WITH TIME ZONE NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_schema_migrations_version ON schema_migrations(version); + ` + default: + return fmt.Errorf("unsupported dialect: %s", r.dialect) + } + + _, err := r.db.ExecContext(ctx, createSQL) + if err != nil { + return fmt.Errorf("failed to create migration table: %w", err) + } + + return nil +} + +// GetAppliedMigrations returns a list of applied migration versions +func (r *MigrationRunner) GetAppliedMigrations(ctx context.Context) (map[string]bool, error) { + rows, err := r.db.QueryContext(ctx, "SELECT version FROM schema_migrations") + if err != nil { + return nil, fmt.Errorf("failed to query applied migrations: %w", err) + } + defer func() { _ = rows.Close() }() + + applied := make(map[string]bool) + for rows.Next() { + var version string + if err := rows.Scan(&version); err != nil { + return nil, fmt.Errorf("failed to scan migration version: %w", err) + } + applied[version] = true + } + + return applied, rows.Err() +} + +// ApplyMigration applies a single migration +func (r *MigrationRunner) ApplyMigration(ctx context.Context, migration Migration) error { + // Start transaction + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Execute migration SQL + if _, err := tx.ExecContext(ctx, migration.UpSQL); err != nil { + return fmt.Errorf("failed to execute migration %s: %w", migration.Version, err) + } + + // Record migration as applied + var recordSQL string + switch r.dialect { + case "sqlite": + recordSQL = "INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)" + case "postgres": + recordSQL = "INSERT INTO schema_migrations (version, description, applied_at) VALUES ($1, $2, $3)" + } + + if _, err := tx.ExecContext(ctx, recordSQL, migration.Version, migration.Description, time.Now()); err != nil { + return fmt.Errorf("failed to record migration %s: %w", migration.Version, err) + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration %s: %w", migration.Version, err) + } + + return nil +} + +// ApplyMigrations applies all pending migrations +func (r *MigrationRunner) ApplyMigrations(ctx context.Context, migrations []Migration) (int, error) { + // Ensure migration table exists + if err := r.EnsureMigrationTable(ctx); err != nil { + return 0, err + } + + // Get applied migrations + applied, err := r.GetAppliedMigrations(ctx) + if err != nil { + return 0, err + } + + // Sort migrations by version + sort.Slice(migrations, func(i, j int) bool { + return migrations[i].Version < migrations[j].Version + }) + + // Apply pending migrations + appliedCount := 0 + for _, migration := range migrations { + if applied[migration.Version] { + continue // Skip already applied migrations + } + + if err := r.ApplyMigration(ctx, migration); err != nil { + return appliedCount, fmt.Errorf("migration %s failed: %w", migration.Version, err) + } + + appliedCount++ + } + + return appliedCount, nil +} + +// GetMigrationStatus returns the current migration status +func (r *MigrationRunner) GetMigrationStatus(ctx context.Context, availableMigrations []Migration) ([]MigrationStatus, error) { + // Ensure migration table exists + if err := r.EnsureMigrationTable(ctx); err != nil { + return nil, err + } + + // Get applied migrations + applied, err := r.GetAppliedMigrations(ctx) + if err != nil { + return nil, err + } + + // Build status list + var status []MigrationStatus + for _, migration := range availableMigrations { + status = append(status, MigrationStatus{ + Version: migration.Version, + Description: migration.Description, + Applied: applied[migration.Version], + }) + } + + // Sort by version + sort.Slice(status, func(i, j int) bool { + return status[i].Version < status[j].Version + }) + + return status, nil +} + +// MigrationStatus represents the status of a migration +type MigrationStatus struct { + Version string + Description string + Applied bool +} diff --git a/internal/infra/storage/migrations/migration_test.go b/internal/infra/storage/migrations/migration_test.go new file mode 100644 index 00000000..6c411f7c --- /dev/null +++ b/internal/infra/storage/migrations/migration_test.go @@ -0,0 +1,256 @@ +package migrations + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func setupTestDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "migration_test_*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + dbPath := filepath.Join(tmpDir, "test.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + _ = os.RemoveAll(tmpDir) + t.Fatalf("Failed to open database: %v", err) + } + + cleanup := func() { + _ = db.Close() + _ = os.RemoveAll(tmpDir) + } + + return db, cleanup +} + +func TestMigrationRunner_EnsureMigrationTable(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + runner := NewMigrationRunner(db, "sqlite") + ctx := context.Background() + + err := runner.EnsureMigrationTable(ctx) + if err != nil { + t.Fatalf("Failed to ensure migration table: %v", err) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&count) + if err != nil { + t.Fatalf("Failed to query table existence: %v", err) + } + + if count != 1 { + t.Errorf("Expected schema_migrations table to exist, got count: %d", count) + } +} + +func TestMigrationRunner_ApplyMigration(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + runner := NewMigrationRunner(db, "sqlite") + ctx := context.Background() + + if err := runner.EnsureMigrationTable(ctx); err != nil { + t.Fatalf("Failed to ensure migration table: %v", err) + } + + migration := Migration{ + Version: "001", + Description: "Create test table", + UpSQL: ` + CREATE TABLE test_table ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + `, + DownSQL: `DROP TABLE test_table;`, + } + + err := runner.ApplyMigration(ctx, migration) + if err != nil { + t.Fatalf("Failed to apply migration: %v", err) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='test_table'").Scan(&count) + if err != nil { + t.Fatalf("Failed to query table existence: %v", err) + } + + if count != 1 { + t.Errorf("Expected test_table to exist, got count: %d", count) + } + + var version string + err = db.QueryRow("SELECT version FROM schema_migrations WHERE version = ?", migration.Version).Scan(&version) + if err != nil { + t.Fatalf("Failed to query migration record: %v", err) + } + + if version != migration.Version { + t.Errorf("Expected version %s, got %s", migration.Version, version) + } +} + +func TestMigrationRunner_ApplyMigrations(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + runner := NewMigrationRunner(db, "sqlite") + ctx := context.Background() + + migrations := []Migration{ + { + Version: "001", + Description: "Create users table", + UpSQL: ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL + ); + `, + }, + { + Version: "002", + Description: "Create posts table", + UpSQL: ` + CREATE TABLE posts ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + title TEXT NOT NULL + ); + `, + }, + } + + appliedCount, err := runner.ApplyMigrations(ctx, migrations) + if err != nil { + t.Fatalf("Failed to apply migrations: %v", err) + } + + if appliedCount != 2 { + t.Errorf("Expected 2 migrations to be applied, got %d", appliedCount) + } + + var usersCount, postsCount int + _ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'").Scan(&usersCount) + _ = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='posts'").Scan(&postsCount) + + if usersCount != 1 { + t.Errorf("Expected users table to exist") + } + if postsCount != 1 { + t.Errorf("Expected posts table to exist") + } + + appliedCount, err = runner.ApplyMigrations(ctx, migrations) + if err != nil { + t.Fatalf("Failed to apply migrations second time: %v", err) + } + + if appliedCount != 0 { + t.Errorf("Expected 0 migrations to be applied on second run, got %d", appliedCount) + } +} + +func TestMigrationRunner_GetMigrationStatus(t *testing.T) { + db, cleanup := setupTestDB(t) + defer cleanup() + + runner := NewMigrationRunner(db, "sqlite") + ctx := context.Background() + + migrations := []Migration{ + { + Version: "001", + Description: "Migration 1", + UpSQL: "SELECT 1;", + }, + { + Version: "002", + Description: "Migration 2", + UpSQL: "SELECT 1;", + }, + } + + if err := runner.EnsureMigrationTable(ctx); err != nil { + t.Fatalf("Failed to ensure migration table: %v", err) + } + if err := runner.ApplyMigration(ctx, migrations[0]); err != nil { + t.Fatalf("Failed to apply migration: %v", err) + } + + status, err := runner.GetMigrationStatus(ctx, migrations) + if err != nil { + t.Fatalf("Failed to get migration status: %v", err) + } + + if len(status) != 2 { + t.Fatalf("Expected 2 status entries, got %d", len(status)) + } + + if !status[0].Applied { + t.Errorf("Expected migration 001 to be applied") + } + if status[0].Version != "001" { + t.Errorf("Expected version 001, got %s", status[0].Version) + } + + if status[1].Applied { + t.Errorf("Expected migration 002 to not be applied") + } + if status[1].Version != "002" { + t.Errorf("Expected version 002, got %s", status[1].Version) + } +} + +func TestGetSQLiteMigrations(t *testing.T) { + migrations := GetSQLiteMigrations() + + if len(migrations) == 0 { + t.Fatal("Expected at least one SQLite migration") + } + + first := migrations[0] + if first.Version == "" { + t.Error("Expected migration to have a version") + } + if first.Description == "" { + t.Error("Expected migration to have a description") + } + if first.UpSQL == "" { + t.Error("Expected migration to have UpSQL") + } +} + +func TestGetPostgresMigrations(t *testing.T) { + migrations := GetPostgresMigrations() + + if len(migrations) == 0 { + t.Fatal("Expected at least one PostgreSQL migration") + } + + first := migrations[0] + if first.Version == "" { + t.Error("Expected migration to have a version") + } + if first.Description == "" { + t.Error("Expected migration to have a description") + } + if first.UpSQL == "" { + t.Error("Expected migration to have UpSQL") + } +} diff --git a/internal/infra/storage/migrations/postgres_migrations.go b/internal/infra/storage/migrations/postgres_migrations.go new file mode 100644 index 00000000..ee8081d6 --- /dev/null +++ b/internal/infra/storage/migrations/postgres_migrations.go @@ -0,0 +1,55 @@ +package migrations + +// GetPostgresMigrations returns all PostgreSQL migrations in order +func GetPostgresMigrations() []Migration { + return []Migration{ + { + Version: "001", + Description: "Initial schema - conversations and entries tables", + UpSQL: ` + CREATE TABLE IF NOT EXISTS conversations ( + id VARCHAR(255) PRIMARY KEY, + title TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + model VARCHAR(255), + tags JSONB, + summary TEXT, + optimized_messages JSONB, + token_stats JSONB, + cost_stats JSONB, + title_generated BOOLEAN DEFAULT FALSE, + title_invalidated BOOLEAN DEFAULT FALSE, + title_generation_time TIMESTAMP WITH TIME ZONE + ); + + CREATE TABLE IF NOT EXISTS conversation_entries ( + id BIGSERIAL PRIMARY KEY, + conversation_id VARCHAR(255) NOT NULL, + entry_data JSONB NOT NULL, + sequence_number INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_conversations_created_at ON conversations(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_conversation_entries_conversation_id ON conversation_entries(conversation_id); + CREATE INDEX IF NOT EXISTS idx_conversation_entries_sequence ON conversation_entries(conversation_id, sequence_number); + CREATE INDEX IF NOT EXISTS idx_conversations_tags ON conversations USING gin(tags); + CREATE INDEX IF NOT EXISTS idx_conversations_title_invalidated ON conversations(title_invalidated, title_generated); + `, + DownSQL: ` + DROP INDEX IF EXISTS idx_conversations_title_invalidated; + DROP INDEX IF EXISTS idx_conversations_tags; + DROP INDEX IF EXISTS idx_conversation_entries_sequence; + DROP INDEX IF EXISTS idx_conversation_entries_conversation_id; + DROP INDEX IF EXISTS idx_conversations_created_at; + DROP INDEX IF EXISTS idx_conversations_updated_at; + DROP TABLE IF EXISTS conversation_entries; + DROP TABLE IF EXISTS conversations; + `, + }, + } +} diff --git a/internal/infra/storage/migrations/sqlite_migrations.go b/internal/infra/storage/migrations/sqlite_migrations.go new file mode 100644 index 00000000..7207e0fe --- /dev/null +++ b/internal/infra/storage/migrations/sqlite_migrations.go @@ -0,0 +1,38 @@ +package migrations + +// GetSQLiteMigrations returns all SQLite migrations in order +func GetSQLiteMigrations() []Migration { + return []Migration{ + { + Version: "001", + Description: "Initial schema - conversations table", + UpSQL: ` + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + messages TEXT NOT NULL, + optimized_messages TEXT, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + request_count INTEGER NOT NULL DEFAULT 0, + cost_stats TEXT DEFAULT '{}', + models TEXT DEFAULT '[]', + tags TEXT DEFAULT '[]', + summary TEXT DEFAULT '', + title_generated BOOLEAN DEFAULT FALSE, + title_invalidated BOOLEAN DEFAULT FALSE, + title_generation_time DATETIME, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC); + `, + DownSQL: ` + DROP INDEX IF EXISTS idx_conversations_updated_at; + DROP TABLE IF EXISTS conversations; + `, + }, + } +} diff --git a/internal/infra/storage/postgres.go b/internal/infra/storage/postgres.go index b4693dce..a3f8ae6c 100644 --- a/internal/infra/storage/postgres.go +++ b/internal/infra/storage/postgres.go @@ -8,6 +8,8 @@ import ( "time" domain "github.com/inference-gateway/cli/internal/domain" + migrations "github.com/inference-gateway/cli/internal/infra/storage/migrations" + _ "github.com/lib/pq" ) @@ -16,6 +18,11 @@ type PostgresStorage struct { db *sql.DB } +// DB returns the underlying database connection +func (s *PostgresStorage) DB() *sql.DB { + return s.db +} + // verifyPostgresAvailable checks if PostgreSQL is available func verifyPostgresAvailable(config PostgresConfig) error { dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", @@ -70,64 +77,32 @@ func NewPostgresStorage(config PostgresConfig) (*PostgresStorage, error) { storage := &PostgresStorage{db: db} - if err := storage.createTables(ctx); err != nil { + if err := storage.runMigrations(ctx); err != nil { _ = db.Close() - return nil, fmt.Errorf("failed to create tables: %w", err) + return nil, fmt.Errorf("failed to run migrations: %w", err) } return storage, nil } -// createTables creates the necessary tables for conversation storage -func (s *PostgresStorage) createTables(ctx context.Context) error { - schema := ` - CREATE TABLE IF NOT EXISTS conversations ( - id VARCHAR(255) PRIMARY KEY, - title TEXT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - message_count INTEGER NOT NULL DEFAULT 0, - model VARCHAR(255), - tags JSONB, - summary TEXT, - optimized_messages JSONB, - token_stats JSONB, - cost_stats JSONB, - title_generated BOOLEAN DEFAULT FALSE, - title_invalidated BOOLEAN DEFAULT FALSE, - title_generation_time TIMESTAMP WITH TIME ZONE - ); - - CREATE TABLE IF NOT EXISTS conversation_entries ( - id BIGSERIAL PRIMARY KEY, - conversation_id VARCHAR(255) NOT NULL, - entry_data JSONB NOT NULL, - sequence_number INTEGER NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC); - CREATE INDEX IF NOT EXISTS idx_conversations_created_at ON conversations(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_conversation_entries_conversation_id ON conversation_entries(conversation_id); - CREATE INDEX IF NOT EXISTS idx_conversation_entries_sequence ON conversation_entries(conversation_id, sequence_number); - CREATE INDEX IF NOT EXISTS idx_conversations_tags ON conversations USING gin(tags); - CREATE INDEX IF NOT EXISTS idx_conversations_title_invalidated ON conversations(title_invalidated, title_generated); - ` - - if _, err := s.db.ExecContext(ctx, schema); err != nil { - return err - } - - migrationSchema := ` - ALTER TABLE conversations ADD COLUMN IF NOT EXISTS title_generated BOOLEAN DEFAULT FALSE; - ALTER TABLE conversations ADD COLUMN IF NOT EXISTS title_invalidated BOOLEAN DEFAULT FALSE; - ALTER TABLE conversations ADD COLUMN IF NOT EXISTS title_generation_time TIMESTAMP WITH TIME ZONE; - ALTER TABLE conversations ADD COLUMN IF NOT EXISTS optimized_messages JSONB; - ALTER TABLE conversations ADD COLUMN IF NOT EXISTS cost_stats JSONB; - ` - - _, _ = s.db.ExecContext(ctx, migrationSchema) +// runMigrations applies all pending database migrations +func (s *PostgresStorage) runMigrations(ctx context.Context) error { + runner := migrations.NewMigrationRunner(s.db, "postgres") + + // Get all PostgreSQL migrations + allMigrations := migrations.GetPostgresMigrations() + + // Apply migrations + appliedCount, err := runner.ApplyMigrations(ctx, allMigrations) + if err != nil { + return fmt.Errorf("failed to apply migrations: %w", err) + } + + // Log applied migrations count (only if any were applied) + if appliedCount > 0 { + // Migrations were applied, but we don't log here as this is a library + _ = appliedCount + } return nil } diff --git a/internal/infra/storage/sqlite.go b/internal/infra/storage/sqlite.go index 107adc77..67f6ee3b 100644 --- a/internal/infra/storage/sqlite.go +++ b/internal/infra/storage/sqlite.go @@ -10,6 +10,7 @@ import ( "time" domain "github.com/inference-gateway/cli/internal/domain" + migrations "github.com/inference-gateway/cli/internal/infra/storage/migrations" _ "github.com/mattn/go-sqlite3" ) @@ -19,6 +20,11 @@ type SQLiteStorage struct { path string } +// DB returns the underlying database connection +func (s *SQLiteStorage) DB() *sql.DB { + return s.db +} + // NewSQLiteStorage creates a new SQLite storage instance func NewSQLiteStorage(config SQLiteConfig) (*SQLiteStorage, error) { if err := verifySQLiteAvailable(); err != nil { @@ -44,14 +50,33 @@ func NewSQLiteStorage(config SQLiteConfig) (*SQLiteStorage, error) { path: config.Path, } - if err := storage.createTables(); err != nil { + if err := storage.runMigrations(); err != nil { _ = db.Close() - return nil, fmt.Errorf("failed to create tables: %w", err) + return nil, fmt.Errorf("failed to run migrations: %w", err) } return storage, nil } +// runMigrations applies all pending database migrations +func (s *SQLiteStorage) runMigrations() error { + ctx := context.Background() + runner := migrations.NewMigrationRunner(s.db, "sqlite") + + allMigrations := migrations.GetSQLiteMigrations() + + appliedCount, err := runner.ApplyMigrations(ctx, allMigrations) + if err != nil { + return fmt.Errorf("failed to apply migrations: %w", err) + } + + if appliedCount > 0 { + _ = appliedCount + } + + return nil +} + // verifySQLiteAvailable checks if SQLite is available on the system func verifySQLiteAvailable() error { db, err := sql.Open("sqlite3", ":memory:") @@ -72,85 +97,6 @@ func verifySQLiteAvailable() error { return nil } -// createTables creates the simplified single-table conversation storage -func (s *SQLiteStorage) createTables() error { - var hasCorrectSchema int - err := s.db.QueryRow(` - SELECT COUNT(*) FROM sqlite_master - WHERE type='table' AND name='conversations' - AND sql LIKE '%messages TEXT NOT NULL%' - AND sql LIKE '%models TEXT%' - AND sql LIKE '%tags TEXT%' - AND sql LIKE '%summary TEXT%' - `).Scan(&hasCorrectSchema) - if err != nil { - return err - } - - if hasCorrectSchema > 0 { - return nil - } - - newSchema := ` - CREATE TABLE IF NOT EXISTS conversations_new ( - id TEXT PRIMARY KEY, -- Session ID - title TEXT NOT NULL, -- Conversation title - count INTEGER NOT NULL DEFAULT 0, -- Message count - messages TEXT NOT NULL, -- JSON array of all messages - optimized_messages TEXT, -- JSON array of optimized messages - total_input_tokens INTEGER NOT NULL DEFAULT 0, -- Total input tokens used - total_output_tokens INTEGER NOT NULL DEFAULT 0, -- Total output tokens used - request_count INTEGER NOT NULL DEFAULT 0, -- Number of API requests made - cost_stats TEXT DEFAULT '{}', -- JSON object of cost statistics - models TEXT DEFAULT '[]', -- JSON array of models used - tags TEXT DEFAULT '[]', -- JSON array of tags - summary TEXT DEFAULT '', -- Conversation summary - title_generated BOOLEAN DEFAULT FALSE, - title_invalidated BOOLEAN DEFAULT FALSE, - title_generation_time DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_conversations_new_updated_at ON conversations_new(updated_at DESC); - ` - - if _, err := s.db.Exec(newSchema); err != nil { - return err - } - - migrationQuery := ` - INSERT OR IGNORE INTO conversations_new (id, title, count, messages, optimized_messages, total_input_tokens, total_output_tokens, created_at, updated_at) - SELECT - c.id, - c.title, - c.message_count, - '[' || GROUP_CONCAT(ce.entry_data) || ']' as messages, - NULL as optimized_messages, -- No optimized messages for migrated data - 0 as total_input_tokens, -- Start with 0 for migrated data - 0 as total_output_tokens, -- Start with 0 for migrated data - c.created_at, - c.updated_at - FROM conversations c - LEFT JOIN conversation_entries ce ON c.id = ce.conversation_id - WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='conversations') - GROUP BY c.id, c.title, c.message_count, c.created_at, c.updated_at; - ` - - _, _ = s.db.Exec(migrationQuery) - - renameSchema := ` - DROP TABLE IF EXISTS conversations; - ALTER TABLE conversations_new RENAME TO conversations; - ` - - if _, err := s.db.Exec(renameSchema); err != nil { - return err - } - - return nil -} - // SaveConversation saves a conversation with its entries using simplified schema func (s *SQLiteStorage) SaveConversation(ctx context.Context, conversationID string, entries []domain.ConversationEntry, metadata ConversationMetadata) error { modelsUsed := make(map[string]bool)