diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index d6b53bd8..5d9e07a5 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -308,6 +308,7 @@ {"id":"bd-ce75","content_hash":"025d43c12e9cc08c6d1db0b4a97f7a086a1a9f24f07769d48a7e2666d04ea217","title":"Test parent issue","description":"","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-07T16:08:24.952167-08:00","updated_at":"2025-11-07T22:07:17.343848-08:00","closed_at":"2025-11-07T22:07:17.34385-08:00","source_repo":"."} {"id":"bd-chsc","content_hash":"ea167029efad3c506e42dfc20748a6ada0914aa93cb04caa14a48ca223386365","title":"Test lowercase p0","description":"","status":"closed","priority":0,"issue_type":"task","created_at":"2025-11-05T12:58:41.457875-08:00","updated_at":"2025-11-05T12:58:44.721486-08:00","closed_at":"2025-11-05T12:58:44.721486-08:00","source_repo":"."} {"id":"bd-cjxp","content_hash":"2a2c0aa49be01be64c5e0a6bd24ebd7b762846d31a06fd8e9360672fb476b879","title":"Bug P0","description":"","status":"closed","priority":0,"issue_type":"bug","assignee":"alice","created_at":"2025-11-07T19:00:22.536449-08:00","updated_at":"2025-11-07T22:07:17.345535-08:00","closed_at":"2025-11-07T21:55:09.429643-08:00","source_repo":"."} +{"id":"bd-ckvw","content_hash":"ca02c9be5b672a144fd2348f5b18b1ea6082e74a8de0349809785e05f9a91144","title":"Add schema compatibility probe to prevent silent migration failures","description":"Issue #262 revealed a serious bug: migrations may fail silently, causing UNIQUE constraint errors later.\n\nRoot cause:\n- sqlite.New() runs migrations once on open\n- checkVersionMismatch() prints 'database will be upgraded automatically' but only updates metadata\n- If migrations fail or daemon runs older version, queries expecting new columns fail with 'no such column'\n- Import logic misinterprets this as 'not found' and tries INSERT on existing ID\n- Result: UNIQUE constraint failed: issues.id\n\nFix strategy (minimal):\n1. Add schema probe in sqlite.New() after RunMigrations\n - SELECT all expected columns from all tables with LIMIT 0\n - If fails, retry RunMigrations and probe again\n - If still fails, return fatal error with clear message\n2. Fix checkVersionMismatch to not claim 'will upgrade' unless probe passes\n3. Only update bd_version after successful migration probe\n4. Add schema verification before import operations\n5. Map 'no such column' errors to clear actionable message\n\nRelated: #262","design":"Minimal path (now includes daemon gating):\n\n1. Schema probe in sqlite.New()\n - After RunMigrations, verify all expected columns exist\n - SELECT id, title, description, created_at, updated_at, closed_at, content_hash, external_ref, source_repo, compacted_at, compacted_at_commit FROM issues LIMIT 0\n - Also probe: dependencies, labels, events, dirty_issues, export_hashes, snapshots, child_counters\n - If probe fails: retry RunMigrations once, probe again\n - If still fails: return fatal error with missing columns/tables\n\n2. Fix checkVersionMismatch()\n - Don't claim 'will be upgraded automatically' unless probe verified\n - Only update bd_version after successful probe\n\n3. Better error surfacing\n - Wrap storage errors: if 'no such column/table', return ErrSchemaIncompatible\n - Actionable message: 'Database schema is incompatible. Run bd doctor to diagnose.'\n\n4. Add 'bd doctor' command\n - Runs migrations + probe\n - Reports missing columns/tables\n - Suggests fixes (upgrade daemon, run migrations manually, etc.)\n - Exit 1 if incompatible\n\n5. Daemon version gating (REQUIRED - prevents future schema bugs)\n - On RPC connect, client/daemon exchange semver\n - If client.minor \u003e daemon.minor: refuse RPC, print 'Client vX.Y requires daemon upgrade. Run: bd daemons killall'\n - Forces users to restart daemon when bd binary is upgraded\n - Prevents stale daemon serving requests with old schema assumptions\n - Already documented best practice, now enforced\n\nEstimated effort: M-L (3-5h with daemon gating + bd doctor)","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-11-08T13:23:26.934246-08:00","updated_at":"2025-11-08T13:53:29.219542-08:00","closed_at":"2025-11-08T13:53:29.219542-08:00","source_repo":"."} {"id":"bd-csvy","content_hash":"88e2ed15c2fe9d9622b16daa530907af7069ef69e621c74dc2a2fafa1da4ac8c","title":"Add tests for merge driver auto-config in bd init","description":"Add comprehensive tests for the merge driver auto-configuration functionality in `bd init`.\n\n**Test cases needed:**\n- Auto-install in quiet mode\n- Skip with --skip-merge-driver flag\n- Detect already-installed merge driver\n- Append to existing .gitattributes\n- Interactive prompt behavior (if feasible)\n\n**File:** `cmd/bd/init_test.go`","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-05T19:27:04.133078-08:00","updated_at":"2025-11-06T18:19:16.233673-08:00","closed_at":"2025-11-06T15:56:36.014814-08:00","source_repo":".","dependencies":[{"issue_id":"bd-csvy","depends_on_id":"bd-32nm","type":"discovered-from","created_at":"2025-11-05T19:27:04.134299-08:00","created_by":"daemon"}]} {"id":"bd-d19a","content_hash":"5ff9ba5e70c3e3eeaff40887421797e30dfb75e56e97fcaaf3f3d32332f22aa2","title":"Fix import failure on missing parent issues","description":"Import process fails atomically when JSONL references deleted parent issues. Implement hybrid solution: topological sorting + parent resurrection to handle deleted parents gracefully while maintaining referential integrity. See docs/import-bug-analysis-bd-3xq.md for full analysis.","status":"closed","priority":0,"issue_type":"epic","created_at":"2025-11-04T12:31:30.994759-08:00","updated_at":"2025-11-05T00:08:42.814239-08:00","closed_at":"2025-11-05T00:08:42.814243-08:00","source_repo":"."} {"id":"bd-d33c","content_hash":"d0820d5dd6ea4ab198e013861d3d7d01da701daa8ab8ec59ad5ef855e6f83b2b","title":"Separate process/lock/PID concerns into process.go","description":"Create internal/daemonrunner/process.go with: acquireDaemonLock, PID file read/write, stopDaemon, isDaemonRunning, getPIDFilePath, socket path helpers, version check.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-01T11:41:14.871122-07:00","updated_at":"2025-11-01T23:43:55.66159-07:00","closed_at":"2025-11-01T23:43:55.66159-07:00","source_repo":"."} diff --git a/cmd/bd/autoflush.go b/cmd/bd/autoflush.go index d0f83db6..b042b064 100644 --- a/cmd/bd/autoflush.go +++ b/cmd/bd/autoflush.go @@ -276,9 +276,14 @@ func checkVersionMismatch() { fmt.Fprintf(os.Stderr, "%s\n\n", yellow("⚠️ Some features may not work correctly. Rebuild: go build -o bd ./cmd/bd")) } else if cmp > 0 { // Binary is newer than database + // Migrations should have already run in sqlite.New() - verify they succeeded fmt.Fprintf(os.Stderr, "%s\n", yellow("⚠️ Your binary appears NEWER than the database.")) - fmt.Fprintf(os.Stderr, "%s\n\n", yellow("⚠️ The database will be upgraded automatically.")) - // Update stored version to current + + // Note: Schema probe already ran in sqlite.New() (bd-ckvw) + // If we got here, migrations succeeded. Update version. + fmt.Fprintf(os.Stderr, "%s\n\n", yellow("⚠️ Database schema has been verified and upgraded.")) + + // Update stored version to current (only after schema verification passed) _ = store.SetMetadata(ctx, "bd_version", Version) } } diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 85495948..33a507f7 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -50,7 +50,8 @@ var doctorCmd = &cobra.Command{ This command checks: - If .beads/ directory exists - - Database version and schema compatibility + - Database version and migration status + - Schema compatibility (all required tables and columns present) - Whether using hash-based vs sequential IDs - If CLI version is current (checks GitHub releases) - Multiple database files @@ -129,6 +130,13 @@ func runDiagnostics(path string) doctorResult { result.OverallOK = false } + // Check 2a: Schema compatibility (bd-ckvw) + schemaCheck := checkSchemaCompatibility(path) + result.Checks = append(result.Checks, schemaCheck) + if schemaCheck.Status == statusError { + result.OverallOK = false + } + // Check 3: ID format (hash vs sequential) idCheck := checkIDFormat(path) result.Checks = append(result.Checks, idCheck) @@ -1179,6 +1187,90 @@ func checkGitHooks(path string) doctorCheck { } } +func checkSchemaCompatibility(path string) doctorCheck { + beadsDir := filepath.Join(path, ".beads") + + // 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 (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", "file:"+dbPath+"?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)") + 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"}, + "dependencies": {"issue_id", "depends_on_id", "type"}, + "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) + _, 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) + 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", + } +} + func init() { rootCmd.AddCommand(doctorCmd) } diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index dcbf6bb9..088992ef 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -56,11 +56,23 @@ func (s *Server) checkVersionCompatibility(clientVersion string) error { clientVersion, ServerVersion) } - // Compare full versions - daemon should be >= client for backward compatibility + // Compare full versions - daemon must be >= client (bd-ckvw: strict minor version gating) + // This prevents stale daemons from serving requests with old schema assumptions cmp := semver.Compare(serverVer, clientVer) if cmp < 0 { - // Server is older than client within same major version - may be missing features - return fmt.Errorf("version mismatch: daemon %s is older than client %s. Upgrade and restart daemon: 'bd daemon --stop && bd daemon'", + // Server is older than client - refuse connection + // Extract minor versions for clearer error message + serverMinor := semver.MajorMinor(serverVer) + clientMinor := semver.MajorMinor(clientVer) + + if serverMinor != clientMinor { + // Minor version mismatch - schema may be incompatible + return fmt.Errorf("version mismatch: client v%s requires daemon upgrade (daemon is v%s). The client may expect schema changes not present in this daemon version. Run: bd daemons killall", + clientVersion, ServerVersion) + } + + // Patch version difference - usually safe but warn + return fmt.Errorf("version mismatch: daemon v%s is older than client v%s. Upgrade and restart daemon: bd daemons killall", ServerVersion, clientVersion) } diff --git a/internal/rpc/version_test.go b/internal/rpc/version_test.go index b94780d2..b811fc7c 100644 --- a/internal/rpc/version_test.go +++ b/internal/rpc/version_test.go @@ -323,7 +323,7 @@ func TestVersionCheckMessage(t *testing.T) { serverVersion: testVersion100, clientVersion: "1.1.0", expectError: true, - errorContains: "daemon 1.0.0 is older than client 1.1.0", + errorContains: "client v1.1.0 requires daemon upgrade", }, { name: "Compatible versions", diff --git a/internal/storage/sqlite/schema_probe.go b/internal/storage/sqlite/schema_probe.go new file mode 100644 index 00000000..5af56f74 --- /dev/null +++ b/internal/storage/sqlite/schema_probe.go @@ -0,0 +1,121 @@ +// Package sqlite - schema compatibility probing +package sqlite + +import ( + "database/sql" + "fmt" + "strings" +) + +// ErrSchemaIncompatible is returned when the database schema is incompatible with the current version +var ErrSchemaIncompatible = fmt.Errorf("database schema is incompatible") + +// expectedSchema defines all expected tables and their required columns +// This is used to verify migrations completed successfully +var expectedSchema = map[string][]string{ + "issues": { + "id", "title", "description", "design", "acceptance_criteria", "notes", + "status", "priority", "issue_type", "assignee", "estimated_minutes", + "created_at", "updated_at", "closed_at", "content_hash", "external_ref", + "compaction_level", "compacted_at", "compacted_at_commit", "original_size", + }, + "dependencies": {"issue_id", "depends_on_id", "type", "created_at", "created_by"}, + "labels": {"issue_id", "label"}, + "comments": {"id", "issue_id", "author", "text", "created_at"}, + "events": {"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, + "config": {"key", "value"}, + "metadata": {"key", "value"}, + "dirty_issues": {"issue_id", "marked_at"}, + "export_hashes": {"issue_id", "content_hash", "exported_at"}, + "child_counters": {"parent_id", "last_child"}, + "issue_snapshots": {"id", "issue_id", "snapshot_time", "compaction_level", "original_size", "compressed_size", "original_content", "archived_events"}, + "compaction_snapshots": {"id", "issue_id", "compaction_level", "snapshot_json", "created_at"}, + "repo_mtimes": {"repo_path", "jsonl_path", "mtime_ns", "last_checked"}, +} + +// SchemaProbeResult contains the results of a schema compatibility check +type SchemaProbeResult struct { + Compatible bool + MissingTables []string + MissingColumns map[string][]string // table -> missing columns + ErrorMessage string +} + +// probeSchema verifies all expected tables and columns exist +// Returns SchemaProbeResult with details about any missing schema elements +func probeSchema(db *sql.DB) SchemaProbeResult { + result := SchemaProbeResult{ + Compatible: true, + MissingTables: []string{}, + MissingColumns: make(map[string][]string), + } + + for table, expectedCols := range expectedSchema { + // Try to query the table with all expected columns + query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", strings.Join(expectedCols, ", "), table) + _, err := db.Exec(query) + + if err != nil { + errMsg := err.Error() + + // Check if table doesn't exist + if strings.Contains(errMsg, "no such table") { + result.Compatible = false + result.MissingTables = append(result.MissingTables, table) + continue + } + + // Check if column doesn't exist + if strings.Contains(errMsg, "no such column") { + result.Compatible = false + // Try to find which columns are missing + missingCols := findMissingColumns(db, table, expectedCols) + if len(missingCols) > 0 { + result.MissingColumns[table] = missingCols + } + } + } + } + + // Build error message if incompatible + if !result.Compatible { + var parts []string + if len(result.MissingTables) > 0 { + parts = append(parts, fmt.Sprintf("missing tables: %s", strings.Join(result.MissingTables, ", "))) + } + if len(result.MissingColumns) > 0 { + for table, cols := range result.MissingColumns { + parts = append(parts, fmt.Sprintf("missing columns in %s: %s", table, strings.Join(cols, ", "))) + } + } + result.ErrorMessage = strings.Join(parts, "; ") + } + + return result +} + +// findMissingColumns determines which columns are missing from a table +func findMissingColumns(db *sql.DB, table string, expectedCols []string) []string { + missing := []string{} + + for _, col := range expectedCols { + query := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) + _, err := db.Exec(query) + if err != nil && strings.Contains(err.Error(), "no such column") { + missing = append(missing, col) + } + } + + return missing +} + +// verifySchemaCompatibility runs schema probe and returns detailed error on failure +func verifySchemaCompatibility(db *sql.DB) error { + result := probeSchema(db) + + if !result.Compatible { + return fmt.Errorf("%w: %s", ErrSchemaIncompatible, result.ErrorMessage) + } + + return nil +} diff --git a/internal/storage/sqlite/schema_probe_test.go b/internal/storage/sqlite/schema_probe_test.go new file mode 100644 index 00000000..e09312b0 --- /dev/null +++ b/internal/storage/sqlite/schema_probe_test.go @@ -0,0 +1,207 @@ +package sqlite + +import ( + "database/sql" + "testing" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" +) + +func TestProbeSchema_AllTablesPresent(t *testing.T) { + // Create in-memory database with full schema + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Initialize schema and run migrations + if _, err := db.Exec(schema); err != nil { + t.Fatalf("failed to initialize schema: %v", err) + } + if err := RunMigrations(db); err != nil { + t.Fatalf("failed to run migrations: %v", err) + } + + // Run schema probe + result := probeSchema(db) + + // Should be compatible + if !result.Compatible { + t.Errorf("expected schema to be compatible, got: %s", result.ErrorMessage) + } + if len(result.MissingTables) > 0 { + t.Errorf("unexpected missing tables: %v", result.MissingTables) + } + if len(result.MissingColumns) > 0 { + t.Errorf("unexpected missing columns: %v", result.MissingColumns) + } +} + +func TestProbeSchema_MissingTable(t *testing.T) { + // Create in-memory database without child_counters table + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Create minimal schema (just issues table) + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + design TEXT NOT NULL DEFAULT '', + acceptance_criteria TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + priority INTEGER NOT NULL DEFAULT 2, + issue_type TEXT NOT NULL DEFAULT 'task', + assignee TEXT, + estimated_minutes INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at DATETIME, + content_hash TEXT, + external_ref TEXT, + compaction_level INTEGER DEFAULT 0, + compacted_at DATETIME, + compacted_at_commit TEXT, + original_size INTEGER + ) + `) + if err != nil { + t.Fatalf("failed to create issues table: %v", err) + } + + // Run schema probe + result := probeSchema(db) + + // Should not be compatible + if result.Compatible { + t.Error("expected schema to be incompatible (missing tables)") + } + if len(result.MissingTables) == 0 { + t.Error("expected missing tables to be reported") + } +} + +func TestProbeSchema_MissingColumn(t *testing.T) { + // Create in-memory database with issues table missing content_hash + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Create issues table WITHOUT content_hash column + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + design TEXT NOT NULL DEFAULT '', + acceptance_criteria TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + priority INTEGER NOT NULL DEFAULT 2, + issue_type TEXT NOT NULL DEFAULT 'task', + assignee TEXT, + estimated_minutes INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at DATETIME, + external_ref TEXT, + compaction_level INTEGER DEFAULT 0, + compacted_at DATETIME, + compacted_at_commit TEXT, + original_size INTEGER + ); + CREATE TABLE dependencies ( + issue_id TEXT NOT NULL, + depends_on_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'blocks', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT NOT NULL, + PRIMARY KEY (issue_id, depends_on_id) + ); + CREATE TABLE labels (issue_id TEXT NOT NULL, label TEXT NOT NULL, PRIMARY KEY (issue_id, label)); + CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id TEXT NOT NULL, author TEXT NOT NULL, text TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE events (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id TEXT NOT NULL, event_type TEXT NOT NULL, actor TEXT NOT NULL, old_value TEXT, new_value TEXT, comment TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE dirty_issues (issue_id TEXT PRIMARY KEY, marked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE export_hashes (issue_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, exported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE child_counters (parent_id TEXT PRIMARY KEY, last_child INTEGER NOT NULL DEFAULT 0); + CREATE TABLE issue_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id TEXT NOT NULL, snapshot_time DATETIME NOT NULL, compaction_level INTEGER NOT NULL, original_size INTEGER NOT NULL, compressed_size INTEGER NOT NULL, original_content TEXT NOT NULL, archived_events TEXT); + CREATE TABLE compaction_snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id TEXT NOT NULL, compaction_level INTEGER NOT NULL, snapshot_json BLOB NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + CREATE TABLE repo_mtimes (repo_path TEXT PRIMARY KEY, jsonl_path TEXT NOT NULL, mtime_ns INTEGER NOT NULL, last_checked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP); + `) + if err != nil { + t.Fatalf("failed to create tables: %v", err) + } + + // Run schema probe + result := probeSchema(db) + + // Should not be compatible + if result.Compatible { + t.Error("expected schema to be incompatible (missing content_hash column)") + } + if len(result.MissingColumns) == 0 { + t.Error("expected missing columns to be reported") + } + if _, ok := result.MissingColumns["issues"]; !ok { + t.Error("expected missing columns in issues table") + } +} + +func TestVerifySchemaCompatibility(t *testing.T) { + // Create in-memory database with full schema + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Initialize schema and run migrations + if _, err := db.Exec(schema); err != nil { + t.Fatalf("failed to initialize schema: %v", err) + } + if err := RunMigrations(db); err != nil { + t.Fatalf("failed to run migrations: %v", err) + } + + // Verify schema compatibility + err = verifySchemaCompatibility(db) + if err != nil { + t.Errorf("expected schema to be compatible, got error: %v", err) + } +} + +func TestVerifySchemaCompatibility_Incompatible(t *testing.T) { + // Create in-memory database with minimal schema + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Create minimal schema + _, err = db.Exec(`CREATE TABLE issues (id TEXT PRIMARY KEY, title TEXT NOT NULL)`) + if err != nil { + t.Fatalf("failed to create issues table: %v", err) + } + + // Verify schema compatibility + err = verifySchemaCompatibility(db) + if err == nil { + t.Error("expected schema incompatibility error, got nil") + } + if err != nil && err != ErrSchemaIncompatible { + // Check that error wraps ErrSchemaIncompatible + t.Logf("got error: %v", err) + } +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 02c88561..a56132af 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -78,6 +78,21 @@ func New(path string) (*SQLiteStorage, error) { return nil, err } + // Verify schema compatibility after migrations (bd-ckvw) + // First attempt + if err := verifySchemaCompatibility(db); err != nil { + // Schema probe failed - retry migrations once + if retryErr := RunMigrations(db); retryErr != nil { + return nil, fmt.Errorf("migration retry failed after schema probe failure: %w (original: %v)", retryErr, err) + } + + // Probe again after retry + if err := verifySchemaCompatibility(db); err != nil { + // Still failing - return fatal error with clear message + return nil, fmt.Errorf("schema probe failed after migration retry: %w. Database may be corrupted or from incompatible version. Run 'bd doctor' to diagnose", err) + } + } + // Convert to absolute path for consistency (but keep :memory: as-is) absPath := path if path != ":memory:" {