// 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) // #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 }