bd sync: 2025-12-27 15:56:42

This commit is contained in:
Steve Yegge
2025-12-27 15:56:42 -08:00
parent 87f535a65e
commit c8b912cbe6
179 changed files with 3051 additions and 10283 deletions

View File

@@ -155,9 +155,9 @@ func CheckSchemaCompatibility(path string) DoctorCheck {
}
}
// Open database (bd-ckvw: schema probe)
// Open database (bd-ckvw: This will run migrations and schema probe)
// Note: We can't use the global 'store' because doctor can check arbitrary paths
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
db, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)")
if err != nil {
return DoctorCheck{
Name: "Schema Compatibility",
@@ -244,30 +244,13 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
}
// Open database in read-only mode for integrity check
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)")
if err != nil {
// Check if JSONL recovery is possible
jsonlCount, _, jsonlErr := CountJSONLIssues(filepath.Join(beadsDir, "issues.jsonl"))
if jsonlErr != nil {
jsonlCount, _, jsonlErr = CountJSONLIssues(filepath.Join(beadsDir, "beads.jsonl"))
}
if jsonlErr == nil && jsonlCount > 0 {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: fmt.Sprintf("Failed to open database (JSONL has %d issues for recovery)", jsonlCount),
Detail: err.Error(),
Fix: "Run 'bd doctor --fix' to recover from JSONL backup",
}
}
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Failed to open database for integrity check",
Detail: err.Error(),
Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup",
}
}
defer db.Close()
@@ -276,28 +259,11 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
// This checks the entire database for corruption
rows, err := db.Query("PRAGMA integrity_check")
if err != nil {
// Check if JSONL recovery is possible
jsonlCount, _, jsonlErr := CountJSONLIssues(filepath.Join(beadsDir, "issues.jsonl"))
if jsonlErr != nil {
jsonlCount, _, jsonlErr = CountJSONLIssues(filepath.Join(beadsDir, "beads.jsonl"))
}
if jsonlErr == nil && jsonlCount > 0 {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: fmt.Sprintf("Failed to run integrity check (JSONL has %d issues for recovery)", jsonlCount),
Detail: err.Error(),
Fix: "Run 'bd doctor --fix' to recover from JSONL backup",
}
}
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Failed to run integrity check",
Detail: err.Error(),
Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup",
}
}
defer rows.Close()
@@ -320,59 +286,28 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
}
}
// Any other result indicates corruption - check if JSONL recovery is possible
jsonlCount, _, jsonlErr := CountJSONLIssues(filepath.Join(beadsDir, "issues.jsonl"))
if jsonlErr != nil {
// Try alternate name
jsonlCount, _, jsonlErr = CountJSONLIssues(filepath.Join(beadsDir, "beads.jsonl"))
}
if jsonlErr == nil && jsonlCount > 0 {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: fmt.Sprintf("Database corruption detected (JSONL has %d issues for recovery)", jsonlCount),
Detail: strings.Join(results, "; "),
Fix: "Run 'bd doctor --fix' to recover from JSONL backup",
}
}
// Any other result indicates corruption
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Database corruption detected",
Detail: strings.Join(results, "; "),
Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup",
Fix: "Database may need recovery. Export with 'bd export' if possible, then restore from backup or reinitialize",
}
}
// CheckDatabaseJSONLSync checks if database and JSONL are in sync
func CheckDatabaseJSONLSync(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Resolve database path (respects metadata.json override).
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
}
// Find JSONL file (respects metadata.json override when set).
jsonlPath := ""
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
if cfg.JSONLExport != "" && !isSystemJSONLFilename(cfg.JSONLExport) {
p := cfg.JSONLPath(beadsDir)
if _, err := os.Stat(p); err == nil {
jsonlPath = p
}
}
}
if jsonlPath == "" {
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
testPath := filepath.Join(beadsDir, name)
if _, err := os.Stat(testPath); err == nil {
jsonlPath = testPath
break
}
// Find JSONL file
var jsonlPath string
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
testPath := filepath.Join(beadsDir, name)
if _, err := os.Stat(testPath); err == nil {
jsonlPath = testPath
break
}
}
@@ -398,7 +333,7 @@ func CheckDatabaseJSONLSync(path string) DoctorCheck {
jsonlCount, jsonlPrefixes, jsonlErr := CountJSONLIssues(jsonlPath)
// Single database open for all queries (instead of 3 separate opens)
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
// Database can't be opened. If JSONL has issues, suggest recovery.
if jsonlErr == nil && jsonlCount > 0 {
@@ -455,16 +390,11 @@ func CheckDatabaseJSONLSync(path string) DoctorCheck {
// Use JSONL error if we got it earlier
if jsonlErr != nil {
fixMsg := "Run 'bd doctor --fix' to attempt recovery"
if strings.Contains(jsonlErr.Error(), "malformed") {
fixMsg = "Run 'bd doctor --fix' to back up and regenerate the JSONL from the database"
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "Unable to read JSONL file",
Detail: jsonlErr.Error(),
Fix: fixMsg,
}
}
@@ -571,7 +501,7 @@ func FixDBJSONLSync(path string) error {
// getDatabaseVersionFromPath reads the database version from the given path
func getDatabaseVersionFromPath(dbPath string) string {
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
if err != nil {
return "unknown"
}
@@ -690,92 +620,3 @@ func isNoDbModeConfigured(beadsDir string) bool {
return cfg.NoDb
}
// CheckDatabaseSize warns when the database has accumulated many closed issues.
// This is purely informational - pruning is NEVER auto-fixed because it
// permanently deletes data. Users must explicitly run 'bd cleanup' to prune.
//
// Config: doctor.suggest_pruning_issue_count (default: 5000, 0 = disabled)
//
// DESIGN NOTE: This check intentionally has NO auto-fix. Unlike other doctor
// checks that fix configuration or sync issues, pruning is destructive and
// irreversible. The user must make an explicit decision to delete their
// closed issue history. We only provide guidance, never action.
func CheckDatabaseSize(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Get database path
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// If no database, skip this check
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return DoctorCheck{
Name: "Large Database",
Status: StatusOK,
Message: "N/A (no database)",
}
}
// Read threshold from config (default 5000, 0 = disabled)
threshold := 5000
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)")
if err != nil {
return DoctorCheck{
Name: "Large Database",
Status: StatusOK,
Message: "N/A (unable to open database)",
}
}
defer db.Close()
// Check for custom threshold in config table
var thresholdStr string
err = db.QueryRow("SELECT value FROM config WHERE key = ?", "doctor.suggest_pruning_issue_count").Scan(&thresholdStr)
if err == nil {
if _, err := fmt.Sscanf(thresholdStr, "%d", &threshold); err != nil {
threshold = 5000 // Reset to default on parse error
}
}
// If disabled, return OK
if threshold == 0 {
return DoctorCheck{
Name: "Large Database",
Status: StatusOK,
Message: "Check disabled (threshold = 0)",
}
}
// Count closed issues
var closedCount int
err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'closed'").Scan(&closedCount)
if err != nil {
return DoctorCheck{
Name: "Large Database",
Status: StatusOK,
Message: "N/A (unable to count issues)",
}
}
// Check against threshold
if closedCount > threshold {
return DoctorCheck{
Name: "Large Database",
Status: StatusWarning,
Message: fmt.Sprintf("%d closed issues (threshold: %d)", closedCount, threshold),
Detail: "Large number of closed issues may impact performance",
Fix: "Consider running 'bd cleanup --older-than 90' to prune old closed issues",
}
}
return DoctorCheck{
Name: "Large Database",
Status: StatusOK,
Message: fmt.Sprintf("%d closed issues (threshold: %d)", closedCount, threshold),
}
}