Files
beads/cmd/bd/doctor/database.go
Dustin Brown d3ccd5cfba Fix Dolt backend init/daemon/doctor; prevent accidental SQLite artifacts; add integration tests; clean up lint (#1218)
* /{cmd,internal}: get dolt backend init working and allow issue creation

* /{website,internal,docs,cmd}: integration tests and more split backend fixes

* /{cmd,internal}: fix lint issues

* /cmd/bd/doctor/integrity.go: fix unable to query issues bug with dolt backend

* /cmd/bd/daemon.go: remove debug logging
2026-01-20 17:34:00 -08:00

1199 lines
37 KiB
Go

package doctor
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
storagefactory "github.com/steveyegge/beads/internal/storage/factory"
"gopkg.in/yaml.v3"
)
// localConfig represents the config.yaml structure for no-db mode detection
type localConfig struct {
SyncBranch string `yaml:"sync-branch"`
NoDb bool `yaml:"no-db"`
}
// CheckDatabaseVersion checks the database version and migration status
func CheckDatabaseVersion(path string, cliVersion string) DoctorCheck {
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: directory-backed store; version lives in metadata table.
if backend == configfile.BackendDolt {
doltPath := filepath.Join(beadsDir, "dolt")
if _, err := os.Stat(doltPath); os.IsNotExist(err) {
// If JSONL exists, treat as fresh clone / needs init.
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
_, issuesErr := os.Stat(issuesJSONL)
_, beadsErr := os.Stat(beadsJSONL)
if issuesErr == nil || beadsErr == nil {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: "Fresh clone detected (no dolt database)",
Detail: "Storage: Dolt",
Fix: "Run 'bd init --backend dolt' to create and hydrate the dolt database",
}
}
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "No dolt database found",
Detail: "Storage: Dolt",
Fix: "Run 'bd init --backend dolt' to create database",
}
}
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "Unable to open database",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Run 'bd init --backend dolt' (or remove and re-init .beads/dolt if corrupted)",
}
}
defer func() { _ = store.Close() }()
dbVersion, err := store.GetMetadata(ctx, "bd_version")
if err != nil {
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "Unable to read database version",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Database may be corrupted. Try re-initializing the dolt database with 'bd init --backend dolt'",
}
}
if dbVersion == "" {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: "Database missing version metadata",
Detail: "Storage: Dolt",
Fix: "Run 'bd migrate' or re-run 'bd init --backend dolt' to set version metadata",
}
}
if dbVersion != cliVersion {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, cliVersion),
Detail: "Storage: Dolt",
Fix: "Update bd CLI and re-run (dolt metadata will be updated automatically by the daemon)",
}
}
return DoctorCheck{
Name: "Database",
Status: StatusOK,
Message: fmt.Sprintf("version %s", dbVersion),
Detail: "Storage: Dolt",
}
}
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
// Fall back to canonical database name
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// Check if database file exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
// Check if JSONL exists
// Check canonical (issues.jsonl) first, then legacy (beads.jsonl)
issuesJSONL := filepath.Join(beadsDir, "issues.jsonl")
beadsJSONL := filepath.Join(beadsDir, "beads.jsonl")
var jsonlPath string
if _, err := os.Stat(issuesJSONL); err == nil {
jsonlPath = issuesJSONL
} else if _, err := os.Stat(beadsJSONL); err == nil {
jsonlPath = beadsJSONL
}
if jsonlPath != "" {
// JSONL exists but no database - check if this is no-db mode or fresh clone
// Use proper YAML parsing to detect no-db mode
if isNoDbModeConfigured(beadsDir) {
return DoctorCheck{
Name: "Database",
Status: StatusOK,
Message: "JSONL-only mode",
Detail: "Using issues.jsonl (no SQLite database)",
}
}
// This is a fresh clone - JSONL exists but no database and not no-db mode
// Count issues and detect prefix for helpful suggestion
issueCount := countIssuesInJSONLFile(jsonlPath)
prefix := detectPrefixFromJSONL(jsonlPath)
message := "Fresh clone detected (no database)"
detail := fmt.Sprintf("Found %d issue(s) in JSONL that need to be imported", issueCount)
fix := "Run 'bd init' to hydrate the database from JSONL"
if prefix != "" {
fix = fmt.Sprintf("Run 'bd init' to hydrate the database (detected prefix: %s)", prefix)
}
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: message,
Detail: detail,
Fix: fix,
}
}
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "No beads.db found",
Fix: "Run 'bd init' to create database",
}
}
// Get database version
dbVersion := getDatabaseVersionFromPath(dbPath)
if dbVersion == "unknown" {
return DoctorCheck{
Name: "Database",
Status: StatusError,
Message: "Unable to read database version",
Detail: "Storage: SQLite",
Fix: "Database may be corrupted. Try 'bd migrate'",
}
}
if dbVersion == "pre-0.17.5" {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: fmt.Sprintf("version %s (very old)", dbVersion),
Detail: "Storage: SQLite",
Fix: "Run 'bd migrate' to upgrade database schema",
}
}
if dbVersion != cliVersion {
return DoctorCheck{
Name: "Database",
Status: StatusWarning,
Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, cliVersion),
Detail: "Storage: SQLite",
Fix: "Run 'bd migrate' to sync database with CLI version",
}
}
return DoctorCheck{
Name: "Database",
Status: StatusOK,
Message: fmt.Sprintf("version %s", dbVersion),
Detail: "Storage: SQLite",
}
}
// CheckSchemaCompatibility checks if all required tables and columns are present
func CheckSchemaCompatibility(path string) DoctorCheck {
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: no SQLite schema probe. Instead, run a lightweight query sanity check.
if backend == configfile.BackendDolt {
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err != nil || !info.IsDir() {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusOK,
Message: "N/A (no database)",
}
}
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusError,
Message: "Failed to open database",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
}
}
defer func() { _ = store.Close() }()
// Exercise core tables/views.
if _, err := store.GetStatistics(ctx); err != nil {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusError,
Message: "Database schema is incomplete or incompatible",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Re-run 'bd init --backend dolt' or remove and re-initialize .beads/dolt if corrupted",
}
}
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusOK,
Message: "Basic queries succeeded",
Detail: "Storage: Dolt",
}
}
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
// Fall back to canonical database name
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// If no database, skip this check
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusOK,
Message: "N/A (no database)",
}
}
// Open database for schema probe
// Note: We can't use the global 'store' because doctor can check arbitrary paths
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
if err != nil {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusError,
Message: "Failed to open database",
Detail: err.Error(),
Fix: "Database may be corrupted. Try 'bd migrate' or restore from backup",
}
}
defer db.Close()
// Run schema probe (defined in internal/storage/sqlite/schema_probe.go)
// This is a simplified version since we can't import the internal package directly
// Check all critical tables and columns
criticalChecks := map[string][]string{
"issues": {"id", "title", "content_hash", "external_ref", "compacted_at", "close_reason", "pinned", "sender", "ephemeral"},
"dependencies": {"issue_id", "depends_on_id", "type", "metadata", "thread_id"},
"child_counters": {"parent_id", "last_child"},
"export_hashes": {"issue_id", "content_hash"},
}
var missingElements []string
for table, columns := range criticalChecks {
// Try to query all columns
query := fmt.Sprintf(
"SELECT %s FROM %s LIMIT 0",
strings.Join(columns, ", "),
table,
) // #nosec G201 -- table/column names sourced from hardcoded map
_, err := db.Exec(query)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "no such table") {
missingElements = append(missingElements, fmt.Sprintf("table:%s", table))
} else if strings.Contains(errMsg, "no such column") {
// Find which columns are missing
for _, col := range columns {
colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) // #nosec G201 -- names come from static schema definition
if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") {
missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col))
}
}
}
}
}
if len(missingElements) > 0 {
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusError,
Message: "Database schema is incomplete or incompatible",
Detail: fmt.Sprintf("Missing: %s", strings.Join(missingElements, ", ")),
Fix: "Run 'bd migrate' to upgrade schema, or if daemon is running an old version, run 'bd daemons killall' to restart",
}
}
return DoctorCheck{
Name: "Schema Compatibility",
Status: StatusOK,
Message: "All required tables and columns present",
}
}
// CheckDatabaseIntegrity runs SQLite's PRAGMA integrity_check
func CheckDatabaseIntegrity(path string) DoctorCheck {
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: SQLite PRAGMA integrity_check doesn't apply.
// We do a lightweight read-only sanity check instead.
if backend == configfile.BackendDolt {
if info, err := os.Stat(filepath.Join(beadsDir, "dolt")); err != nil || !info.IsDir() {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusOK,
Message: "N/A (no database)",
}
}
ctx := context.Background()
store, err := storagefactory.NewFromConfigWithOptions(ctx, beadsDir, storagefactory.Options{ReadOnly: true})
if err != nil {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Failed to open database",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
Fix: "Re-run 'bd init --backend dolt' or remove and re-initialize .beads/dolt if corrupted",
}
}
defer func() { _ = store.Close() }()
// Minimal checks: metadata + statistics. If these work, the store is at least readable.
if _, err := store.GetMetadata(ctx, "bd_version"); err != nil {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Basic query failed",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
}
}
if _, err := store.GetStatistics(ctx); err != nil {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: "Basic query failed",
Detail: fmt.Sprintf("Storage: Dolt\n\nError: %v", err),
}
}
return DoctorCheck{
Name: "Database Integrity",
Status: StatusOK,
Message: "Basic query check passed",
Detail: "Storage: Dolt (no SQLite integrity_check equivalent)",
}
}
// Get database path (same logic as CheckSchemaCompatibility)
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: "Database Integrity",
Status: StatusOK,
Message: "N/A (no database)",
}
}
// Open database in read-only mode for integrity check
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
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"))
}
jsonlAvailable := jsonlErr == nil && jsonlCount > 0
errorType, recoverySteps := classifyDatabaseError(err.Error(), jsonlCount, jsonlAvailable)
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: errorType,
Detail: fmt.Sprintf("%s\n\nError: %s", recoverySteps, err.Error()),
Fix: "See recovery steps above",
}
}
defer db.Close()
// Run PRAGMA integrity_check
// 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"))
}
jsonlAvailable := jsonlErr == nil && jsonlCount > 0
errorType, recoverySteps := classifyDatabaseError(err.Error(), jsonlCount, jsonlAvailable)
// Override default error type for this specific case
if errorType == "Failed to open database" {
errorType = "Failed to run integrity check"
}
return DoctorCheck{
Name: "Database Integrity",
Status: StatusError,
Message: errorType,
Detail: fmt.Sprintf("%s\n\nError: %s", recoverySteps, err.Error()),
Fix: "See recovery steps above",
}
}
defer rows.Close()
var results []string
for rows.Next() {
var result string
if err := rows.Scan(&result); err != nil {
continue
}
results = append(results, result)
}
// "ok" means no corruption detected
if len(results) == 1 && results[0] == "ok" {
return DoctorCheck{
Name: "Database Integrity",
Status: StatusOK,
Message: "No corruption detected",
}
}
// 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",
}
}
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",
}
}
// CheckDatabaseJSONLSync checks if database and JSONL are in sync
func CheckDatabaseJSONLSync(path string) DoctorCheck {
backend, beadsDir := getBackendAndBeadsDir(path)
// Dolt backend: JSONL is a derived compatibility artifact (export-only today).
// The SQLite-style import/export divergence checks don't apply.
if backend == configfile.BackendDolt {
// 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
}
}
}
if jsonlPath == "" {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "N/A (no JSONL file)",
}
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "N/A (dolt backend)",
Detail: "JSONL is derived from Dolt (export-only); import-only sync checks do not apply",
}
}
// 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
}
}
}
// If no database, skip this check
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "N/A (no database)",
}
}
// If no JSONL, skip this check
if jsonlPath == "" {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "N/A (no JSONL file)",
}
}
// Try to read JSONL first (doesn't depend on database)
jsonlCount, jsonlPrefixes, jsonlErr := CountJSONLIssues(jsonlPath)
// Single database open for all queries (instead of 3 separate opens)
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
if err != nil {
// Database can't be opened. If JSONL has issues, suggest recovery.
if jsonlErr == nil && jsonlCount > 0 {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: fmt.Sprintf("Database cannot be opened but JSONL contains %d issues", jsonlCount),
Detail: err.Error(),
Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)),
}
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "Unable to open database",
Detail: err.Error(),
}
}
defer db.Close()
// Get database count (exclude ephemeral/wisp issues - they're never exported to JSONL)
var dbCount int
err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE ephemeral = 0 OR ephemeral IS NULL").Scan(&dbCount)
if err != nil {
// Database opened but can't query. If JSONL has issues, suggest recovery.
if jsonlErr == nil && jsonlCount > 0 {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: fmt.Sprintf("Database cannot be queried but JSONL contains %d issues", jsonlCount),
Detail: err.Error(),
Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)),
}
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "Unable to query database",
Detail: err.Error(),
}
}
// Get database prefix
var dbPrefix string
err = db.QueryRow("SELECT value FROM config WHERE key = ?", "issue_prefix").Scan(&dbPrefix)
if err != nil && err != sql.ErrNoRows {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "Unable to read database prefix",
Detail: err.Error(),
}
}
// 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,
}
}
// Check for issues
var issues []string
// Count mismatch
if dbCount != jsonlCount {
issues = append(issues, fmt.Sprintf("Count mismatch: database has %d issues, JSONL has %d", dbCount, jsonlCount))
}
// GH#885: Content-level comparison to detect status mismatches
// This catches the case where counts match but issue statuses differ
// (e.g., JSONL shows "open" but DB shows "closed")
var statusMismatchDetail string
if dbCount == jsonlCount && jsonlCount > 0 {
statusMismatches, contentErr := compareIssueStatuses(db, jsonlPath)
if contentErr != nil {
// Non-fatal: log warning but continue with other checks
fmt.Fprintf(os.Stderr, "Warning: status comparison failed: %v\n", contentErr)
} else if len(statusMismatches) > 0 {
// Report up to 3 mismatches in detail
statusMismatchDetail = "Status mismatches detected:\n"
showCount := len(statusMismatches)
if showCount > 3 {
showCount = 3
}
for i := 0; i < showCount; i++ {
statusMismatchDetail += fmt.Sprintf(" %s: DB=%s, JSONL=%s\n",
statusMismatches[i].ID,
statusMismatches[i].DBStatus,
statusMismatches[i].JSONLStatus)
}
if len(statusMismatches) > 3 {
statusMismatchDetail += fmt.Sprintf(" ... and %d more\n", len(statusMismatches)-3)
}
issues = append(issues, fmt.Sprintf("Status mismatch: %d issue(s) have different status in DB vs JSONL", len(statusMismatches)))
}
}
// Prefix mismatch (only check most common prefix in JSONL)
if dbPrefix != "" && len(jsonlPrefixes) > 0 {
var mostCommonPrefix string
maxCount := 0
for prefix, count := range jsonlPrefixes {
if count > maxCount {
maxCount = count
mostCommonPrefix = prefix
}
}
// Only warn if majority of issues have wrong prefix
// BUT: recognize that <prefix>-mol and <prefix>-wisp are valid variants
// created by molecule/wisp workflows (see internal/storage/sqlite/queries.go:166-170)
if mostCommonPrefix != dbPrefix && maxCount > jsonlCount/2 {
// Check if the common prefix is a known workflow variant of the db prefix
isValidVariant := false
for _, suffix := range []string{"-mol", "-wisp", "-eph"} {
if mostCommonPrefix == dbPrefix+suffix {
isValidVariant = true
break
}
}
if !isValidVariant {
issues = append(issues, fmt.Sprintf("Prefix mismatch: database uses %q but most JSONL issues use %q", dbPrefix, mostCommonPrefix))
}
}
}
// If we found issues, report them
if len(issues) > 0 {
// Provide direction-specific guidance
var fixMsg string
var detail string
if dbCount > jsonlCount {
fixMsg = "Run 'bd doctor --fix' to automatically export DB to JSONL, or manually run 'bd export'"
} else if jsonlCount > dbCount {
fixMsg = "Run 'bd doctor --fix' to automatically import JSONL to DB, or manually run 'bd sync --import-only'"
} else {
// Equal counts but other issues (like prefix mismatch or status mismatch)
fixMsg = "Run 'bd doctor --fix' to fix automatically, or manually run 'bd sync --import-only' or 'bd export' depending on which has newer data"
}
if strings.Contains(strings.Join(issues, " "), "Prefix mismatch") {
fixMsg = "Run 'bd import -i .beads/issues.jsonl --rename-on-import' to fix prefixes"
}
// GH#885: For status mismatches, provide specific guidance and include detail
if strings.Contains(strings.Join(issues, " "), "Status mismatch") {
fixMsg = "Run 'bd export -o .beads/issues.jsonl' to update JSONL from DB (DB is usually source of truth)"
detail = statusMismatchDetail
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: strings.Join(issues, "; "),
Detail: detail,
Fix: fixMsg,
}
}
// Check modification times (only if counts match)
dbInfo, err := os.Stat(dbPath)
if err != nil {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "Unable to check database file",
}
}
jsonlInfo, err := os.Stat(jsonlPath)
if err != nil {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "Unable to check JSONL file",
}
}
if jsonlInfo.ModTime().After(dbInfo.ModTime()) {
timeDiff := jsonlInfo.ModTime().Sub(dbInfo.ModTime())
if timeDiff > 30*time.Second {
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusWarning,
Message: "JSONL is newer than database",
Fix: "Run 'bd sync --import-only' to import JSONL updates",
}
}
}
return DoctorCheck{
Name: "DB-JSONL Sync",
Status: StatusOK,
Message: "Database and JSONL are in sync",
}
}
// Fix functions
// FixDatabaseConfig auto-detects and fixes metadata.json database/JSONL config mismatches
func FixDatabaseConfig(path string) error {
return fix.DatabaseConfig(path)
}
// FixDBJSONLSync fixes database-JSONL sync issues by running bd sync --import-only
func FixDBJSONLSync(path string) error {
return fix.DBJSONLSync(path)
}
// Helper functions
// classifyDatabaseError classifies a database error and returns appropriate recovery guidance.
// Returns the error type description and recovery steps based on error message and JSONL availability.
func classifyDatabaseError(errMsg string, jsonlCount int, jsonlAvailable bool) (errorType, recoverySteps string) {
switch {
case strings.Contains(errMsg, "database is locked"):
errorType = "Database is locked"
recoverySteps = "1. Check for running bd processes: ps aux | grep bd\n" +
"2. Kill any stale processes\n" +
"3. Remove stale locks: rm .beads/beads.db-shm .beads/beads.db-wal .beads/daemon.lock\n" +
"4. Retry: bd doctor --fix"
case strings.Contains(errMsg, "not a database") || strings.Contains(errMsg, "file is not a database"):
errorType = "File is not a valid SQLite database"
if jsonlAvailable {
recoverySteps = fmt.Sprintf("Database file is corrupted beyond repair.\n\n"+
"Recovery steps:\n"+
"1. Backup corrupt database: mv .beads/beads.db .beads/beads.db.broken\n"+
"2. Rebuild from JSONL (%d issues): bd doctor --fix --force --source=jsonl\n"+
"3. Verify: bd stats", jsonlCount)
} else {
recoverySteps = "Database file is corrupted and no JSONL backup found.\n" +
"Manual recovery required:\n" +
"1. Restore from git: git checkout HEAD -- .beads/issues.jsonl\n" +
"2. Rebuild database: bd doctor --fix --force"
}
case strings.Contains(errMsg, "migration") || strings.Contains(errMsg, "validation failed"):
errorType = "Database migration or validation failed"
if jsonlAvailable {
recoverySteps = fmt.Sprintf("Database has validation errors (possibly orphaned dependencies).\n\n"+
"Recovery steps:\n"+
"1. Backup database: mv .beads/beads.db .beads/beads.db.broken\n"+
"2. Rebuild from JSONL (%d issues): bd doctor --fix --force --source=jsonl\n"+
"3. Verify: bd stats\n\n"+
"Alternative: bd doctor --fix --force (attempts to repair in-place)", jsonlCount)
} else {
recoverySteps = "Database validation failed and no JSONL backup available.\n" +
"Try: bd doctor --fix --force"
}
default:
errorType = "Failed to open database"
if jsonlAvailable {
recoverySteps = fmt.Sprintf("Run 'bd doctor --fix --source=jsonl' to rebuild from JSONL (%d issues)", jsonlCount)
} else {
recoverySteps = "Run 'bd doctor --fix --force' to attempt recovery"
}
}
return
}
// getDatabaseVersionFromPath reads the database version from the given path
func getDatabaseVersionFromPath(dbPath string) string {
db, err := sql.Open("sqlite3", sqliteConnString(dbPath, true))
if err != nil {
return "unknown"
}
defer db.Close()
// Try to read version from metadata table
var version string
err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&version)
if err == nil {
return version
}
// Check if metadata table exists
var tableName string
err = db.QueryRow(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='metadata'
`).Scan(&tableName)
if err == sql.ErrNoRows {
return "pre-0.17.5"
}
return "unknown"
}
// CountJSONLIssues counts issues in the JSONL file and returns the count, prefixes, and any error
func CountJSONLIssues(jsonlPath string) (int, map[string]int, error) {
// jsonlPath is safe: constructed from filepath.Join(beadsDir, hardcoded name)
file, err := os.Open(jsonlPath) //nolint:gosec
if err != nil {
return 0, nil, fmt.Errorf("failed to open JSONL file: %w", err)
}
defer file.Close()
count := 0
prefixes := make(map[string]int)
errorCount := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// Parse JSON to get the ID
var issue map[string]interface{}
if err := json.Unmarshal(line, &issue); err != nil {
errorCount++
continue
}
if id, ok := issue["id"].(string); ok && id != "" {
count++
// Extract prefix (everything before the last dash)
lastDash := strings.LastIndex(id, "-")
if lastDash != -1 {
prefixes[id[:lastDash]]++
} else {
prefixes[id]++
}
}
}
if err := scanner.Err(); err != nil {
return count, prefixes, fmt.Errorf("failed to read JSONL file: %w", err)
}
if errorCount > 0 {
return count, prefixes, fmt.Errorf("skipped %d malformed lines in JSONL", errorCount)
}
return count, prefixes, nil
}
// countIssuesInJSONLFile counts the number of valid issues in a JSONL file
// This is a wrapper around CountJSONLIssues that returns only the count
func countIssuesInJSONLFile(jsonlPath string) int {
count, _, _ := CountJSONLIssues(jsonlPath)
return count
}
// detectPrefixFromJSONL detects the most common prefix in a JSONL file
func detectPrefixFromJSONL(jsonlPath string) string {
_, prefixes, _ := CountJSONLIssues(jsonlPath)
if len(prefixes) == 0 {
return ""
}
// Find the most common prefix
var mostCommonPrefix string
maxCount := 0
for prefix, count := range prefixes {
if count > maxCount {
maxCount = count
mostCommonPrefix = prefix
}
}
return mostCommonPrefix
}
// isNoDbModeConfigured checks if no-db: true is set in config.yaml
// Uses proper YAML parsing to avoid false matches in comments or nested keys
func isNoDbModeConfigured(beadsDir string) bool {
configPath := filepath.Join(beadsDir, "config.yaml")
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from beadsDir
if err != nil {
return false
}
var cfg localConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return false
}
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 := getBackendAndBeadsDir(path)
// 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),
}
}
// statusMismatch represents a status difference between DB and JSONL for an issue
type statusMismatch struct {
ID string
DBStatus string
JSONLStatus string
}
// compareIssueStatuses compares issue statuses between the database and JSONL file.
// Returns a slice of mismatches found. For performance, samples up to 500 issues
// when there are many issues. This is sufficient to detect sync problems while
// keeping the check fast.
//
// GH#885: This detects the case where sync failure leaves JSONL stale while
// the DB has the correct state (e.g., JSONL shows "open" but DB shows "closed").
func compareIssueStatuses(db *sql.DB, jsonlPath string) ([]statusMismatch, error) {
// Read JSONL statuses into a map
jsonlStatuses, err := readJSONLStatuses(jsonlPath)
if err != nil {
return nil, fmt.Errorf("failed to read JSONL statuses: %w", err)
}
if len(jsonlStatuses) == 0 {
return nil, nil // No issues to compare
}
// For performance, sample issues if there are many
// 500 samples is enough to detect sync problems with high confidence
const maxSamples = 500
issueIDs := make([]string, 0, len(jsonlStatuses))
for id := range jsonlStatuses {
issueIDs = append(issueIDs, id)
}
// If we have more issues than the sample size, take a deterministic sample
// by sorting and taking every Nth issue
if len(issueIDs) > maxSamples {
// Sort for deterministic results
slices.Sort(issueIDs)
step := len(issueIDs) / maxSamples
sampled := make([]string, 0, maxSamples)
for i := 0; i < len(issueIDs); i += step {
sampled = append(sampled, issueIDs[i])
if len(sampled) >= maxSamples {
break
}
}
issueIDs = sampled
}
// Query DB statuses for the sampled issues
var mismatches []statusMismatch
for _, id := range issueIDs {
var dbStatus string
err := db.QueryRow("SELECT status FROM issues WHERE id = ?", id).Scan(&dbStatus)
if err == sql.ErrNoRows {
// Issue exists in JSONL but not in DB - this is a count mismatch issue
// which is already caught by the count check
continue
}
if err != nil {
return nil, fmt.Errorf("failed to query status for %s: %w", id, err)
}
jsonlStatus := jsonlStatuses[id]
if dbStatus != jsonlStatus {
mismatches = append(mismatches, statusMismatch{
ID: id,
DBStatus: dbStatus,
JSONLStatus: jsonlStatus,
})
}
}
return mismatches, nil
}
// readJSONLStatuses reads issue IDs and their statuses from a JSONL file.
// Returns a map of issue ID -> status.
func readJSONLStatuses(jsonlPath string) (map[string]string, error) {
// jsonlPath is safe: constructed from filepath.Join(beadsDir, hardcoded name)
file, err := os.Open(jsonlPath) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to open JSONL file: %w", err)
}
defer file.Close()
statuses := make(map[string]string)
scanner := bufio.NewScanner(file)
// Increase buffer for large JSON lines
scanner.Buffer(make([]byte, 0, 1024), 2*1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// Parse just the id and status fields (more efficient than full parse)
var partial struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(line, &partial); err != nil {
continue // Skip malformed lines
}
if partial.ID != "" {
statuses[partial.ID] = partial.Status
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read JSONL file: %w", err)
}
return statuses, nil
}