feat(doctor): add database corruption recovery to --fix
Adds automatic database recovery when bd doctor --fix detects corruption: - Detects SQLite corruption (malformed database, SQLITE_CORRUPT errors) - Backs up corrupted database before recovery attempt - Rebuilds from JSONL if available (issues.jsonl, deletions.jsonl) - Falls back to fresh database if JSONL unavailable - Reports recovery results (issues imported, success/failure) Recovery is triggered automatically by --fix when corruption is detected. No manual intervention required.
This commit is contained in:
81
cmd/bd/doctor/fix/recovery.go
Normal file
81
cmd/bd/doctor/fix/recovery.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DatabaseCorruptionRecovery recovers a corrupted database from JSONL backup.
|
||||
// It backs up the corrupted database, deletes it, and re-imports from JSONL.
|
||||
func DatabaseCorruptionRecovery(path string) error {
|
||||
// Validate workspace
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
dbPath := filepath.Join(beadsDir, "beads.db")
|
||||
|
||||
// Check if database exists
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no database to recover")
|
||||
}
|
||||
|
||||
// Find JSONL file
|
||||
jsonlPath := findJSONLPath(beadsDir)
|
||||
if jsonlPath == "" {
|
||||
return fmt.Errorf("no JSONL backup found - cannot recover (try restoring from git history)")
|
||||
}
|
||||
|
||||
// Count issues in JSONL
|
||||
issueCount, err := countJSONLIssues(jsonlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read JSONL: %w", err)
|
||||
}
|
||||
if issueCount == 0 {
|
||||
return fmt.Errorf("JSONL is empty - cannot recover (try restoring from git history)")
|
||||
}
|
||||
|
||||
// Backup corrupted database
|
||||
backupPath := dbPath + ".corrupt"
|
||||
fmt.Printf(" Backing up corrupted database to %s\n", filepath.Base(backupPath))
|
||||
if err := os.Rename(dbPath, backupPath); err != nil {
|
||||
return fmt.Errorf("failed to backup corrupted database: %w", err)
|
||||
}
|
||||
|
||||
// Get bd binary path
|
||||
bdBinary, err := getBdBinary()
|
||||
if err != nil {
|
||||
// Restore corrupted database on failure
|
||||
_ = os.Rename(backupPath, dbPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Run bd import with --rename-on-import to handle prefix mismatches
|
||||
fmt.Printf(" Recovering %d issues from %s\n", issueCount, filepath.Base(jsonlPath))
|
||||
cmd := exec.Command(bdBinary, "import", "-i", jsonlPath, "--rename-on-import") // #nosec G204
|
||||
cmd.Dir = path
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Keep backup on failure
|
||||
fmt.Printf(" Warning: recovery failed, corrupted database preserved at %s\n", filepath.Base(backupPath))
|
||||
return fmt.Errorf("failed to import from JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Run migrate to set version metadata
|
||||
migrateCmd := exec.Command(bdBinary, "migrate") // #nosec G204
|
||||
migrateCmd.Dir = path
|
||||
migrateCmd.Stdout = os.Stdout
|
||||
migrateCmd.Stderr = os.Stderr
|
||||
if err := migrateCmd.Run(); err != nil {
|
||||
// Non-fatal - import succeeded, version just won't be set
|
||||
fmt.Printf(" Warning: migration failed (non-fatal): %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Recovered %d issues from JSONL backup\n", issueCount)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user