Files
beads/internal/storage/sqlite/schema_probe.go
Marco Del Pin d5e569443d fix(schema): add metadata and thread_id to dependencies expectedSchema
**Problem:**
Schema compatibility probe was failing with "no such column: thread_id"
error when opening databases created before v0.30.5, even after running
migrations. This caused database initialization to fail.
**Root Cause:**
The migration 020_edge_consolidation.go correctly adds the metadata and
thread_id columns to the dependencies table, but schema_probe.go's
expectedSchema map was missing these columns in its validation list.
This caused verifySchemaCompatibility() to fail even though:
1. The columns existed in the actual schema (schema.go:51-52)
2. Migration 020 would add them if missing
3. The database was otherwise valid
**Solution:**
Updated expectedSchema in schema_probe.go to include "metadata" and
"thread_id" in the dependencies table column list. This aligns the schema
probe expectations with the actual schema definition and migration 020
behavior.
**Testing:**
 Tested on 5 databases across version range pre-0.17.5 → v0.30.5
   - All migrations completed successfully
   - Schema probe passes after migration
   - All bd commands work correctly
 No regressions in fresh database initialization
**Impact:**
- Fixes database initialization errors for users upgrading from v0.30.3
- No breaking changes or data migrations required
- Compatible with all existing databases
- Enables smooth migration path for all database versions
Fixes database migration issues reported in v0.30.5 upgrade path.
🤖 Generated with Claude Code
2025-12-18 19:32:26 +01:00

122 lines
4.3 KiB
Go

// 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", "metadata", "thread_id"},
"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) // #nosec G201 -- table/column names sourced from hardcoded schema
_, 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) // #nosec G201 -- table/column names sourced from hardcoded schema
_, 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
}