Skip to content

Commit 61d1420

Browse files
feat: Add database migrations for smooth version upgrades (#344)
## Summary Implements a comprehensive database migration system to ensure smooth CLI version upgrades without breaking existing databases. ### Key Features - ✅ Automatic migration on database initialization - ✅ Version tracking with `schema_migrations` table - ✅ Transaction-based migrations (atomic all-or-nothing) - ✅ Support for SQLite and PostgreSQL - ✅ `infer migrate` command with status checking - ✅ Integration with `infer init --overwrite` - ✅ Comprehensive tests and documentation ## Changes - Created migration infrastructure in `internal/infra/storage/migrations/` - Added `infer migrate` command for manual migration control - Integrated automatic migrations into storage initialization - Added complete documentation in `docs/database-migrations.md` - Created initial migrations for SQLite and PostgreSQL ## Testing - All existing tests pass - 6 new migration tests added and passing - Manual testing completed successfully Fixes #340 --- 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: Eden Reich <[email protected]> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Eden Reich <[email protected]>
1 parent a788d87 commit 61d1420

File tree

15 files changed

+1236
-151
lines changed

15 files changed

+1236
-151
lines changed

.infer/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
logs/*.log
33
history
44
chat_export_*
5-
conversations.db
5+
conversations.db*
66
conversations
77
bin/
88
tmp/

cmd/init.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ This is the recommended command to start working with Inference Gateway CLI in a
3030
func init() {
3131
initCmd.Flags().Bool("overwrite", false, "Overwrite existing files if they already exist")
3232
initCmd.Flags().Bool("userspace", false, "Initialize configuration in user home directory (~/.infer/)")
33+
initCmd.Flags().Bool("skip-migrations", false, "Skip running database migrations")
3334
rootCmd.AddCommand(initCmd)
3435
}
3536

3637
func initializeProject(cmd *cobra.Command) error {
3738
overwrite, _ := cmd.Flags().GetBool("overwrite")
3839
userspace, _ := cmd.Flags().GetBool("userspace")
40+
skipMigrations, _ := cmd.Flags().GetBool("skip-migrations")
3941

4042
var configPath, gitignorePath, scmShortcutsPath, gitShortcutsPath, mcpShortcutsPath, shellsShortcutsPath, exportShortcutsPath, a2aShortcutsPath, mcpPath string
4143

@@ -79,7 +81,7 @@ func initializeProject(cmd *cobra.Command) error {
7981
logs/*.log
8082
history
8183
chat_export_*
82-
conversations.db
84+
conversations.db*
8385
conversations
8486
bin/
8587
tmp/
@@ -148,6 +150,17 @@ tmp/
148150
fmt.Println("")
149151
fmt.Println("Tip: Use /init in chat mode to generate an AGENTS.md file interactively")
150152

153+
if !skipMigrations {
154+
fmt.Println("")
155+
fmt.Println("Running database migrations...")
156+
if err := runMigrations(); err != nil {
157+
fmt.Printf("%s Warning: Failed to run migrations: %v\n", icons.CrossMarkStyle.Render(icons.CrossMark), err)
158+
fmt.Println(" You can run migrations manually with: infer migrate")
159+
} else {
160+
fmt.Printf("%s Database migrations completed successfully\n", icons.CheckMarkStyle.Render(icons.CheckMark))
161+
}
162+
}
163+
151164
return nil
152165
}
153166

cmd/init_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ func TestInitializeProject(t *testing.T) {
2020
{
2121
name: "basic project initialization",
2222
flags: map[string]any{
23-
"overwrite": false,
24-
"userspace": false,
23+
"overwrite": false,
24+
"userspace": false,
25+
"skip-migrations": true,
2526
},
2627
wantFiles: []string{".infer/config.yaml", ".infer/.gitignore"},
2728
wantNoFiles: []string{"AGENTS.md"},
@@ -30,8 +31,9 @@ func TestInitializeProject(t *testing.T) {
3031
{
3132
name: "userspace initialization",
3233
flags: map[string]any{
33-
"overwrite": true,
34-
"userspace": true,
34+
"overwrite": true,
35+
"userspace": true,
36+
"skip-migrations": true,
3537
},
3638
wantFiles: []string{},
3739
wantNoFiles: []string{".infer/config.yaml", ".infer/.gitignore", "AGENTS.md"},

cmd/migrate.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
8+
container "github.com/inference-gateway/cli/internal/container"
9+
storage "github.com/inference-gateway/cli/internal/infra/storage"
10+
migrations "github.com/inference-gateway/cli/internal/infra/storage/migrations"
11+
icons "github.com/inference-gateway/cli/internal/ui/styles/icons"
12+
cobra "github.com/spf13/cobra"
13+
)
14+
15+
var migrateCmd = &cobra.Command{
16+
Use: "migrate",
17+
Short: "Run database migrations",
18+
Long: `Run database migrations to update the schema to the latest version.
19+
20+
This command applies any pending migrations to your database. Migrations are tracked
21+
in the schema_migrations table to ensure they are only applied once.
22+
23+
The command automatically detects your database backend (SQLite, PostgreSQL, JSONL, Redis, Memory)
24+
and applies the appropriate migrations. Note that JSONL, Redis, and Memory storage backends
25+
do not require migrations as they do not use a relational schema.`,
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
status, _ := cmd.Flags().GetBool("status")
28+
if status {
29+
return showMigrationStatus()
30+
}
31+
return runMigrations()
32+
},
33+
}
34+
35+
func init() {
36+
migrateCmd.Flags().Bool("status", false, "Show migration status without applying migrations")
37+
rootCmd.AddCommand(migrateCmd)
38+
}
39+
40+
// runMigrations executes pending database migrations
41+
func runMigrations() error {
42+
cfg, err := getConfigFromViper()
43+
if err != nil {
44+
return fmt.Errorf("failed to get config: %w", err)
45+
}
46+
47+
serviceContainer := container.NewServiceContainer(cfg, V)
48+
49+
conversationStorage := serviceContainer.GetStorage()
50+
51+
defer func() {
52+
if err := conversationStorage.Close(); err != nil {
53+
fmt.Fprintf(os.Stderr, "Warning: failed to close storage: %v\n", err)
54+
}
55+
}()
56+
57+
switch conversationStorage.(type) {
58+
case *storage.SQLiteStorage:
59+
fmt.Printf("%s SQLite database migrations are up to date\n", icons.CheckMarkStyle.Render(icons.CheckMark))
60+
fmt.Println(" All migrations have been applied automatically")
61+
return nil
62+
case *storage.PostgresStorage:
63+
fmt.Printf("%s PostgreSQL database migrations are up to date\n", icons.CheckMarkStyle.Render(icons.CheckMark))
64+
fmt.Println(" All migrations have been applied automatically")
65+
return nil
66+
case *storage.JsonlStorage:
67+
fmt.Println("JSONL storage does not require migrations")
68+
return nil
69+
case *storage.MemoryStorage:
70+
fmt.Println("Memory storage does not require migrations")
71+
return nil
72+
case *storage.RedisStorage:
73+
fmt.Println("Redis storage does not require migrations")
74+
return nil
75+
default:
76+
return fmt.Errorf("unsupported storage backend: %T", conversationStorage)
77+
}
78+
}
79+
80+
// showMigrationStatus displays the current migration status
81+
func showMigrationStatus() error {
82+
cfg, err := getConfigFromViper()
83+
if err != nil {
84+
return fmt.Errorf("failed to get config: %w", err)
85+
}
86+
87+
serviceContainer := container.NewServiceContainer(cfg, V)
88+
89+
conversationStorage := serviceContainer.GetStorage()
90+
91+
defer func() {
92+
if err := conversationStorage.Close(); err != nil {
93+
fmt.Fprintf(os.Stderr, "Warning: failed to close storage: %v\n", err)
94+
}
95+
}()
96+
97+
switch s := conversationStorage.(type) {
98+
case *storage.SQLiteStorage:
99+
return showSQLiteMigrationStatus(s)
100+
case *storage.PostgresStorage:
101+
return showPostgresMigrationStatus(s)
102+
case *storage.JsonlStorage:
103+
fmt.Println("JSONL storage does not require migrations")
104+
return nil
105+
case *storage.MemoryStorage:
106+
fmt.Println("Memory storage does not require migrations")
107+
return nil
108+
case *storage.RedisStorage:
109+
fmt.Println("Redis storage does not require migrations")
110+
return nil
111+
default:
112+
return fmt.Errorf("unsupported storage backend: %T", s)
113+
}
114+
}
115+
116+
// showSQLiteMigrationStatus shows migration status for SQLite
117+
func showSQLiteMigrationStatus(s *storage.SQLiteStorage) error {
118+
ctx := context.Background()
119+
db := s.DB()
120+
if db == nil {
121+
return fmt.Errorf("database connection is nil")
122+
}
123+
124+
runner := migrations.NewMigrationRunner(db, "sqlite")
125+
allMigrations := migrations.GetSQLiteMigrations()
126+
127+
status, err := runner.GetMigrationStatus(ctx, allMigrations)
128+
if err != nil {
129+
return fmt.Errorf("failed to get migration status: %w", err)
130+
}
131+
132+
fmt.Println("SQLite Migration Status:")
133+
fmt.Println()
134+
for _, s := range status {
135+
statusIcon := icons.StyledCrossMark()
136+
statusText := "Pending"
137+
if s.Applied {
138+
statusIcon = icons.StyledCheckMark()
139+
statusText = "Applied"
140+
}
141+
fmt.Printf(" %s Version %s: %s (%s)\n", statusIcon, s.Version, s.Description, statusText)
142+
}
143+
144+
return nil
145+
}
146+
147+
// showPostgresMigrationStatus shows migration status for PostgreSQL
148+
func showPostgresMigrationStatus(s *storage.PostgresStorage) error {
149+
ctx := context.Background()
150+
db := s.DB()
151+
if db == nil {
152+
return fmt.Errorf("database connection is nil")
153+
}
154+
155+
runner := migrations.NewMigrationRunner(db, "postgres")
156+
allMigrations := migrations.GetPostgresMigrations()
157+
158+
status, err := runner.GetMigrationStatus(ctx, allMigrations)
159+
if err != nil {
160+
return fmt.Errorf("failed to get migration status: %w", err)
161+
}
162+
163+
fmt.Println("PostgreSQL Migration Status:")
164+
fmt.Println()
165+
for _, s := range status {
166+
statusIcon := icons.StyledCrossMark()
167+
statusText := "Pending"
168+
if s.Applied {
169+
statusIcon = icons.StyledCheckMark()
170+
statusText = "Applied"
171+
}
172+
fmt.Printf(" %s Version %s: %s (%s)\n", statusIcon, s.Version, s.Description, statusText)
173+
}
174+
175+
return nil
176+
}

0 commit comments

Comments
 (0)