Add comprehensive database corruption recovery capabilities to bd doctor. ## Changes ### New Command Flags - --force: Force repair mode that bypasses database validation - --source: Choose source of truth (auto/jsonl/db) for recovery ### Enhanced Error Classification Improved CheckDatabaseIntegrity() to detect and classify: - Database locked errors (suggests killing processes, removing locks) - Invalid SQLite files (suggests JSONL recovery with exact commands) - Migration/validation failures (orphaned dependencies, etc.) - Generic database errors (context-aware suggestions) Each error type provides: - Specific diagnosis - Step-by-step recovery instructions - Appropriate command examples with new flags ### Force Recovery Implementation New DatabaseCorruptionRecoveryWithOptions() function: - Bypasses database validation when --force is used - Supports explicit source of truth selection - Auto-detects best recovery path when source=auto - Comprehensive rollback on failure - Uses --force --no-git-history in import during force mode ### Integration Updated fix orchestration to pass force and source flags to recovery. ## Usage Examples ```bash # Unopenable database with validation errors bd doctor --fix --force --source=jsonl # Choose specific source of truth bd doctor --fix --source=jsonl # Trust JSONL bd doctor --fix --source=db # Trust database bd doctor --fix --source=auto # Auto-detect (default) # Force recovery with auto-detection bd doctor --fix --force ``` ## Problem Solved Before: When database had validation errors (orphaned dependencies, foreign key violations), all bd commands failed in a catch-22 situation. Could not open database to fix database. Users had to manually delete database files and reinit. After: bd doctor --fix --force detects unopenable databases, provides clear recovery steps, and forces rebuild from JSONL even when database validation fails. ## Backward Compatibility - All new flags are optional with safe defaults - --source defaults to 'auto' (existing behavior) - --force is opt-in only - Existing bd doctor behavior unchanged when flags not used - DatabaseCorruptionRecovery() still exists for compatibility Fixes: bd-pgza
228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
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
|
|
}
|
|
|
|
// DatabaseCorruptionRecoveryWithOptions recovers a corrupted database with force and source selection support.
|
|
//
|
|
// Parameters:
|
|
// - path: workspace path
|
|
// - force: if true, bypasses validation and forces recovery even when database can't be opened
|
|
// - source: source of truth selection ("auto", "jsonl", "db")
|
|
//
|
|
// Force mode is useful when the database has validation errors that prevent normal opening.
|
|
// Source selection allows choosing between JSONL and database when both exist but diverge.
|
|
func DatabaseCorruptionRecoveryWithOptions(path string, force bool, source 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
|
|
dbExists := false
|
|
if _, err := os.Stat(dbPath); err == nil {
|
|
dbExists = true
|
|
}
|
|
|
|
// Find JSONL file
|
|
jsonlPath := findJSONLPath(beadsDir)
|
|
jsonlExists := jsonlPath != ""
|
|
|
|
// Determine source of truth based on --source flag and availability
|
|
var useJSONL bool
|
|
switch source {
|
|
case "jsonl":
|
|
// Explicit JSONL preference
|
|
if !jsonlExists {
|
|
return fmt.Errorf("--source=jsonl specified but no JSONL file found")
|
|
}
|
|
useJSONL = true
|
|
fmt.Println(" Using JSONL as source of truth (--source=jsonl)")
|
|
case "db":
|
|
// Explicit database preference
|
|
if !dbExists {
|
|
return fmt.Errorf("--source=db specified but no database found")
|
|
}
|
|
useJSONL = false
|
|
fmt.Println(" Using database as source of truth (--source=db)")
|
|
case "auto":
|
|
// Auto-detect: prefer JSONL if database is corrupted, otherwise prefer database
|
|
if !dbExists && jsonlExists {
|
|
useJSONL = true
|
|
fmt.Println(" Using JSONL as source of truth (database missing)")
|
|
} else if dbExists && !jsonlExists {
|
|
useJSONL = false
|
|
fmt.Println(" Using database as source of truth (JSONL missing)")
|
|
} else if !dbExists && !jsonlExists {
|
|
return fmt.Errorf("neither database nor JSONL found - cannot recover")
|
|
} else {
|
|
// Both exist - prefer JSONL for recovery since we're in corruption recovery
|
|
useJSONL = true
|
|
fmt.Println(" Using JSONL as source of truth (auto-detected, database appears corrupted)")
|
|
}
|
|
default:
|
|
return fmt.Errorf("invalid --source value: %s (valid values: auto, jsonl, db)", source)
|
|
}
|
|
|
|
// If force mode is enabled, always use JSONL recovery (bypass database validation)
|
|
if force {
|
|
useJSONL = true
|
|
fmt.Println(" Force mode enabled: bypassing database validation, using JSONL recovery")
|
|
}
|
|
|
|
// If using database as source, just run migration (no recovery needed)
|
|
if !useJSONL {
|
|
fmt.Println(" Database is the source of truth - skipping recovery")
|
|
return nil
|
|
}
|
|
|
|
// JSONL recovery path
|
|
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 existing database if it exists
|
|
if dbExists {
|
|
backupPath := dbPath + ".corrupt"
|
|
fmt.Printf(" Backing up database to %s\n", filepath.Base(backupPath))
|
|
if err := os.Rename(dbPath, backupPath); err != nil {
|
|
return fmt.Errorf("failed to backup database: %w", err)
|
|
}
|
|
}
|
|
|
|
// Get bd binary path
|
|
bdBinary, err := getBdBinary()
|
|
if err != nil {
|
|
// Restore database on failure if it existed
|
|
if dbExists {
|
|
backupPath := dbPath + ".corrupt"
|
|
_ = 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))
|
|
importArgs := []string{"import", "-i", jsonlPath, "--rename-on-import"}
|
|
if force {
|
|
// Force mode: skip git history checks, import from working tree
|
|
importArgs = append(importArgs, "--force", "--no-git-history")
|
|
}
|
|
|
|
cmd := exec.Command(bdBinary, importArgs...) // #nosec G204
|
|
cmd.Dir = path
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
// Keep backup on failure
|
|
if dbExists {
|
|
backupPath := dbPath + ".corrupt"
|
|
fmt.Printf(" Warning: recovery failed, 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
|
|
}
|