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 != "" // Check for contradictory flags early if force && source == "db" { return fmt.Errorf("--force and --source=db are contradictory: --force implies the database is broken and recovery should use JSONL. Use --source=jsonl or --source=auto with --force") } // 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 if force { fmt.Println(" Using JSONL as source of truth (--force --source=jsonl)") } else { fmt.Println(" Using JSONL as source of truth (--source=jsonl)") } case "db": // Explicit database preference (already checked for force+db contradiction above) 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 or force is set if force { // Force mode implies database is broken - use JSONL if !jsonlExists { return fmt.Errorf("--force requires JSONL for recovery but no JSONL file found") } useJSONL = true fmt.Println(" Using JSONL as source of truth (--force mode)") } else 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 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 }