fix: Add pre-migration orphan cleanup to avoid chicken-and-egg failure (bd-eko4)

When the database has orphaned foreign key references (dependencies or labels
pointing to non-existent issues), the migration invariant check would fail,
preventing the database from opening. This created a chicken-and-egg problem:

1. bd doctor --fix tries to open the database
2. Opening triggers migrations with invariant checks
3. Invariant check fails due to orphaned refs
4. Fix never runs because database won't open

The fix adds CleanOrphanedRefs() that runs BEFORE captureSnapshot() in
RunMigrations. This automatically cleans up orphaned dependencies and labels
(preserving external:* refs), allowing the database to open normally.

Added test coverage for the cleanup function.
This commit is contained in:
Steve Yegge
2025-12-29 13:46:47 -08:00
parent 819208b8c1
commit 64d5f20b90
3 changed files with 155 additions and 0 deletions

View File

@@ -248,6 +248,111 @@ func TestGetInvariantNames(t *testing.T) {
}
}
func TestCleanOrphanedRefs(t *testing.T) {
db := setupInvariantTestDB(t)
defer db.Close()
// Create a valid issue
_, err := db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
if err != nil {
t.Fatalf("failed to insert test issue: %v", err)
}
// Create valid dependency and label
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('test-1', 'test-1', 'test')`)
if err != nil {
t.Fatalf("failed to insert valid dependency: %v", err)
}
_, err = db.Exec(`INSERT INTO labels (issue_id, label) VALUES ('test-1', 'valid-label')`)
if err != nil {
t.Fatalf("failed to insert valid label: %v", err)
}
// Disable FK constraints to create orphaned refs
_, err = db.Exec(`PRAGMA foreign_keys = OFF`)
if err != nil {
t.Fatalf("failed to disable foreign keys: %v", err)
}
// Create orphaned dependency (issue_id not in issues)
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('orphan-1', 'test-1', 'test')`)
if err != nil {
t.Fatalf("failed to insert orphaned dependency (issue_id): %v", err)
}
// Create orphaned dependency (depends_on_id not in issues)
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('test-1', 'orphan-2', 'test')`)
if err != nil {
t.Fatalf("failed to insert orphaned dependency (depends_on_id): %v", err)
}
// Create external dependency (should NOT be cleaned)
_, err = db.Exec(`INSERT INTO dependencies (issue_id, depends_on_id, created_by) VALUES ('test-1', 'external:proj:cap', 'test')`)
if err != nil {
t.Fatalf("failed to insert external dependency: %v", err)
}
// Create orphaned label
_, err = db.Exec(`INSERT INTO labels (issue_id, label) VALUES ('orphan-3', 'orphan-label')`)
if err != nil {
t.Fatalf("failed to insert orphaned label: %v", err)
}
_, err = db.Exec(`PRAGMA foreign_keys = ON`)
if err != nil {
t.Fatalf("failed to enable foreign keys: %v", err)
}
// Verify we have orphaned refs (checkForeignKeys should fail)
err = checkForeignKeys(db, &Snapshot{})
if err == nil {
t.Error("expected checkForeignKeys to fail with orphaned refs")
}
// Run cleanup
deps, labels, err := CleanOrphanedRefs(db)
if err != nil {
t.Fatalf("CleanOrphanedRefs failed: %v", err)
}
// Should have cleaned 2 orphaned deps (orphan-1 and orphan-2, not external)
if deps != 2 {
t.Errorf("expected 2 orphaned deps cleaned, got %d", deps)
}
// Should have cleaned 1 orphaned label
if labels != 1 {
t.Errorf("expected 1 orphaned label cleaned, got %d", labels)
}
// Verify checkForeignKeys now passes
err = checkForeignKeys(db, &Snapshot{})
if err != nil {
t.Errorf("expected checkForeignKeys to pass after cleanup, got: %v", err)
}
// Verify valid data is still present
var depCount, labelCount int
err = db.QueryRow(`SELECT COUNT(*) FROM dependencies`).Scan(&depCount)
if err != nil {
t.Fatalf("failed to count dependencies: %v", err)
}
// Should have 2: valid self-ref + external ref
if depCount != 2 {
t.Errorf("expected 2 dependencies remaining, got %d", depCount)
}
err = db.QueryRow(`SELECT COUNT(*) FROM labels`).Scan(&labelCount)
if err != nil {
t.Fatalf("failed to count labels: %v", err)
}
// Should have 1: valid label
if labelCount != 1 {
t.Errorf("expected 1 label remaining, got %d", labelCount)
}
}
// setupInvariantTestDB creates an in-memory test database with schema
func setupInvariantTestDB(t *testing.T) *sql.DB {
t.Helper()