fix: bd migrate respects config.json database name and fixes I/O errors
Fixes #204 Multiple critical bugs in bd migrate: 1. **Respects config.json database name**: migrate now loads config.json and uses the configured database name instead of hardcoding beads.db. Users with custom database names (e.g., beady.db) will no longer have their databases renamed. 2. **Fixes disk I/O error (522)**: Clean up orphaned WAL files before reopening database to update schema version. This prevents SQLite error 522 (disk I/O error) when WAL files exist from previous database sessions. 3. **Creates backup before migration**: First migration now creates a timestamped backup (*.backup-pre-migrate-*.db) before renaming database, consistent with hash-id migration behavior. 4. **Cleans up orphaned WAL files**: Removes .db-wal and .db-shm files after migrating old database to prevent stale files. Changes: - Load config.json in migrate command to get target database name - Use cfg.Database instead of hardcoded beads.CanonicalDatabaseName - Add loadOrCreateConfig() helper function - Add cleanupWALFiles() to remove orphaned WAL/SHM files - Create backup before first migration - Clean up WAL files before version update and after migration - Add TestMigrateRespectsConfigJSON test All migration tests passing. Amp-Thread-ID: https://ampcode.com/threads/T-e5b9ddd0-621b-418b-bc52-ba9462975c39 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads"
|
"github.com/steveyegge/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/configfile"
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -45,19 +46,33 @@ This command:
|
|||||||
// Find .beads directory
|
// Find .beads directory
|
||||||
beadsDir := findBeadsDir()
|
beadsDir := findBeadsDir()
|
||||||
if beadsDir == "" {
|
if beadsDir == "" {
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
outputJSON(map[string]interface{}{
|
outputJSON(map[string]interface{}{
|
||||||
"error": "no_beads_directory",
|
"error": "no_beads_directory",
|
||||||
"message": "No .beads directory found. Run 'bd init' first.",
|
"message": "No .beads directory found. Run 'bd init' first.",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n")
|
fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n")
|
||||||
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n")
|
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n")
|
||||||
}
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect all database files
|
// Load config to get target database name (respects user's config.json)
|
||||||
|
cfg, err := loadOrCreateConfig(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "config_load_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect all database files
|
||||||
databases, err := detectDatabases(beadsDir)
|
databases, err := detectDatabases(beadsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -84,8 +99,8 @@ This command:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if beads.db exists and is current
|
// Check if target database exists and is current (use config.json name)
|
||||||
targetPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
targetPath := cfg.DatabasePath(beadsDir)
|
||||||
var currentDB *dbInfo
|
var currentDB *dbInfo
|
||||||
var oldDBs []*dbInfo
|
var oldDBs []*dbInfo
|
||||||
|
|
||||||
@@ -109,7 +124,7 @@ This command:
|
|||||||
color.Green(" ✓ Version matches\n")
|
color.Green(" ✓ Version matches\n")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
color.Yellow(" No beads.db found\n")
|
color.Yellow(" No %s found\n", cfg.Database)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(oldDBs) > 0 {
|
if len(oldDBs) > 0 {
|
||||||
@@ -141,7 +156,7 @@ This command:
|
|||||||
for _, db := range oldDBs {
|
for _, db := range oldDBs {
|
||||||
fmt.Fprintf(os.Stderr, " - %s (version: %s)\n", filepath.Base(db.path), db.version)
|
fmt.Fprintf(os.Stderr, " - %s (version: %s)\n", filepath.Base(db.path), db.version)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to beads.db and remove others.\n")
|
fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to %s and remove others.\n", cfg.Database)
|
||||||
}
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
} else if currentDB != nil && currentDB.version != Version {
|
} else if currentDB != nil && currentDB.version != Version {
|
||||||
@@ -161,7 +176,7 @@ This command:
|
|||||||
} else {
|
} else {
|
||||||
fmt.Println("Dry run mode - no changes will be made")
|
fmt.Println("Dry run mode - no changes will be made")
|
||||||
if needsMigration {
|
if needsMigration {
|
||||||
fmt.Printf("Would migrate: %s → beads.db\n", filepath.Base(oldDBs[0].path))
|
fmt.Printf("Would migrate: %s → %s\n", filepath.Base(oldDBs[0].path), cfg.Database)
|
||||||
}
|
}
|
||||||
if needsVersionUpdate {
|
if needsVersionUpdate {
|
||||||
fmt.Printf("Would update version: %s → %s\n", currentDB.version, Version)
|
fmt.Printf("Would update version: %s → %s\n", currentDB.version, Version)
|
||||||
@@ -173,11 +188,30 @@ This command:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate old database to beads.db
|
// Migrate old database to target name (from config.json)
|
||||||
if needsMigration {
|
if needsMigration {
|
||||||
oldDB := oldDBs[0]
|
oldDB := oldDBs[0]
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
fmt.Printf("Migrating database: %s → beads.db\n", filepath.Base(oldDB.path))
|
fmt.Printf("Migrating database: %s → %s\n", filepath.Base(oldDB.path), cfg.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup before migration
|
||||||
|
if !dryRun {
|
||||||
|
backupPath := strings.TrimSuffix(oldDB.path, ".db") + ".backup-pre-migrate-" + time.Now().Format("20060102-150405") + ".db"
|
||||||
|
if err := copyFile(oldDB.path, backupPath); err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "backup_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if !jsonOutput {
|
||||||
|
color.Green("✓ Created backup: %s\n", filepath.Base(backupPath))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Rename(oldDB.path, targetPath); err != nil {
|
if err := os.Rename(oldDB.path, targetPath); err != nil {
|
||||||
@@ -192,6 +226,9 @@ This command:
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned WAL files from old database
|
||||||
|
_ = cleanupWALFiles(oldDB.path)
|
||||||
|
|
||||||
// Update current DB reference
|
// Update current DB reference
|
||||||
currentDB = oldDB
|
currentDB = oldDB
|
||||||
currentDB.path = targetPath
|
currentDB.path = targetPath
|
||||||
@@ -208,6 +245,9 @@ This command:
|
|||||||
fmt.Printf("Updating schema version: %s → %s\n", currentDB.version, Version)
|
fmt.Printf("Updating schema version: %s → %s\n", currentDB.version, Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up WAL files before opening to avoid "disk I/O error"
|
||||||
|
_ = cleanupWALFiles(currentDB.path)
|
||||||
|
|
||||||
store, err := sqlite.New(currentDB.path)
|
store, err := sqlite.New(currentDB.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -234,7 +274,13 @@ This command:
|
|||||||
}
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
_ = store.Close()
|
|
||||||
|
// Close and checkpoint to finalize the WAL
|
||||||
|
if err := store.Close(); err != nil {
|
||||||
|
if !jsonOutput {
|
||||||
|
color.Yellow("Warning: error closing database: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
color.Green("✓ Version updated\n\n")
|
color.Green("✓ Version updated\n\n")
|
||||||
@@ -369,7 +415,7 @@ This command:
|
|||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
outputJSON(map[string]interface{}{
|
outputJSON(map[string]interface{}{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"current_database": beads.CanonicalDatabaseName,
|
"current_database": cfg.Database,
|
||||||
"version": Version,
|
"version": Version,
|
||||||
"migrated": needsMigration,
|
"migrated": needsMigration,
|
||||||
"version_updated": needsVersionUpdate,
|
"version_updated": needsVersionUpdate,
|
||||||
@@ -377,7 +423,7 @@ This command:
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("\nMigration complete!")
|
fmt.Println("\nMigration complete!")
|
||||||
fmt.Printf("Current database: beads.db (version %s)\n", Version)
|
fmt.Printf("Current database: %s (version %s)\n", cfg.Database, Version)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -582,6 +628,33 @@ func handleUpdateRepoID(dryRun bool, autoYes bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadOrCreateConfig loads config.json or creates default if not found
|
||||||
|
func loadOrCreateConfig(beadsDir string) (*configfile.Config, error) {
|
||||||
|
cfg, err := configfile.Load(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default if no config exists
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = configfile.DefaultConfig(Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupWALFiles removes orphaned WAL and SHM files for a given database path
|
||||||
|
func cleanupWALFiles(dbPath string) error {
|
||||||
|
walPath := dbPath + "-wal"
|
||||||
|
shmPath := dbPath + "-shm"
|
||||||
|
|
||||||
|
// Best effort - don't fail if these don't exist
|
||||||
|
_ = os.Remove(walPath)
|
||||||
|
_ = os.Remove(shmPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts")
|
migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts")
|
||||||
migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration")
|
migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration")
|
||||||
|
|||||||
@@ -125,3 +125,53 @@ func TestFormatDBList(t *testing.T) {
|
|||||||
t.Errorf("Expected version 0.16.0, got %s", result[1]["version"])
|
t.Errorf("Expected version 0.16.0, got %s", result[1]["version"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrateRespectsConfigJSON(t *testing.T) {
|
||||||
|
// Test that migrate respects custom database name from config.json
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||||
|
t.Fatalf("Failed to create .beads directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config.json with custom database name
|
||||||
|
configPath := filepath.Join(beadsDir, "config.json")
|
||||||
|
configData := `{"database": "beady.db", "version": "0.21.1", "jsonl_export": "beady.jsonl"}`
|
||||||
|
if err := os.WriteFile(configPath, []byte(configData), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create config.json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create old database with custom name
|
||||||
|
oldDBPath := filepath.Join(beadsDir, "beady.db")
|
||||||
|
store, err := sqlite.New(oldDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.SetMetadata(ctx, "bd_version", "0.21.1"); err != nil {
|
||||||
|
t.Fatalf("Failed to set version: %v", err)
|
||||||
|
}
|
||||||
|
_ = store.Close()
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
cfg, err := loadOrCreateConfig(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify config respects custom database name
|
||||||
|
if cfg.Database != "beady.db" {
|
||||||
|
t.Errorf("Expected database name 'beady.db', got %s", cfg.Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(beadsDir, "beady.db")
|
||||||
|
actualPath := cfg.DatabasePath(beadsDir)
|
||||||
|
if actualPath != expectedPath {
|
||||||
|
t.Errorf("Expected path %s, got %s", expectedPath, actualPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database exists at custom path
|
||||||
|
if _, err := os.Stat(actualPath); os.IsNotExist(err) {
|
||||||
|
t.Errorf("Database does not exist at custom path: %s", actualPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user