Implemented: - bd migrate --inspect --json: Shows migration plan, db state, warnings - bd info --schema --json: Returns schema details for agents - Migration invariants: Validates migrations post-execution - Added ListMigrations() for introspection Phase 1 (invariants) and Phase 2 (inspection) complete. Next: Wire up MCP tools in beads-mcp server. Amp-Thread-ID: https://ampcode.com/threads/T-c4674660-d640-405f-a929-b664e8699a48 Co-authored-by: Amp <amp@ampcode.com>
205 lines
6.0 KiB
Go
205 lines
6.0 KiB
Go
// Package sqlite - migration safety invariants
|
|
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Snapshot captures database state before migrations for validation
|
|
type Snapshot struct {
|
|
IssueCount int
|
|
ConfigKeys []string
|
|
DependencyCount int
|
|
LabelCount int
|
|
}
|
|
|
|
// MigrationInvariant represents a database invariant that must hold after migrations
|
|
type MigrationInvariant struct {
|
|
Name string
|
|
Description string
|
|
Check func(*sql.DB, *Snapshot) error
|
|
}
|
|
|
|
// invariants is the list of all invariants checked after migrations
|
|
var invariants = []MigrationInvariant{
|
|
{
|
|
Name: "required_config_present",
|
|
Description: "Required config keys must exist",
|
|
Check: checkRequiredConfig,
|
|
},
|
|
{
|
|
Name: "foreign_keys_valid",
|
|
Description: "No orphaned dependencies or labels",
|
|
Check: checkForeignKeys,
|
|
},
|
|
{
|
|
Name: "issue_count_stable",
|
|
Description: "Issue count should not decrease unexpectedly",
|
|
Check: checkIssueCount,
|
|
},
|
|
}
|
|
|
|
// captureSnapshot takes a snapshot of the database state before migrations
|
|
func captureSnapshot(db *sql.DB) (*Snapshot, error) {
|
|
snapshot := &Snapshot{}
|
|
|
|
// Count issues
|
|
err := db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&snapshot.IssueCount)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count issues: %w", err)
|
|
}
|
|
|
|
// Get config keys
|
|
rows, err := db.Query("SELECT key FROM config ORDER BY key")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query config keys: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
snapshot.ConfigKeys = []string{}
|
|
for rows.Next() {
|
|
var key string
|
|
if err := rows.Scan(&key); err != nil {
|
|
return nil, fmt.Errorf("failed to scan config key: %w", err)
|
|
}
|
|
snapshot.ConfigKeys = append(snapshot.ConfigKeys, key)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("error reading config keys: %w", err)
|
|
}
|
|
|
|
// Count dependencies
|
|
err = db.QueryRow("SELECT COUNT(*) FROM dependencies").Scan(&snapshot.DependencyCount)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count dependencies: %w", err)
|
|
}
|
|
|
|
// Count labels
|
|
err = db.QueryRow("SELECT COUNT(*) FROM labels").Scan(&snapshot.LabelCount)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count labels: %w", err)
|
|
}
|
|
|
|
return snapshot, nil
|
|
}
|
|
|
|
// verifyInvariants checks all migration invariants and returns error if any fail
|
|
func verifyInvariants(db *sql.DB, snapshot *Snapshot) error {
|
|
var failures []string
|
|
|
|
for _, invariant := range invariants {
|
|
if err := invariant.Check(db, snapshot); err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", invariant.Name, err))
|
|
}
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
return fmt.Errorf("migration invariants failed:\n - %s", strings.Join(failures, "\n - "))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkRequiredConfig ensures required config keys exist (would have caught GH #201)
|
|
// Only enforces issue_prefix requirement if there are issues in the database
|
|
func checkRequiredConfig(db *sql.DB, snapshot *Snapshot) error {
|
|
// Check current issue count (not snapshot, since migrations may add/remove issues)
|
|
var currentCount int
|
|
err := db.QueryRow("SELECT COUNT(*) FROM issues").Scan(¤tCount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to count issues: %w", err)
|
|
}
|
|
|
|
// Only require issue_prefix if there are issues in the database
|
|
// New databases can exist without issue_prefix until first issue is created
|
|
if currentCount == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Check for required config keys
|
|
var value string
|
|
err = db.QueryRow("SELECT value FROM config WHERE key = 'issue_prefix'").Scan(&value)
|
|
if err == sql.ErrNoRows || value == "" {
|
|
return fmt.Errorf("required config key missing: issue_prefix (database has %d issues)", currentCount)
|
|
} else if err != nil {
|
|
return fmt.Errorf("failed to check config key issue_prefix: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkForeignKeys ensures no orphaned dependencies or labels exist
|
|
func checkForeignKeys(db *sql.DB, snapshot *Snapshot) error {
|
|
// Check for orphaned dependencies (issue_id not in issues)
|
|
var orphanedDepsIssue int
|
|
err := db.QueryRow(`
|
|
SELECT COUNT(*)
|
|
FROM dependencies d
|
|
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = d.issue_id)
|
|
`).Scan(&orphanedDepsIssue)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check orphaned dependencies (issue_id): %w", err)
|
|
}
|
|
if orphanedDepsIssue > 0 {
|
|
return fmt.Errorf("found %d orphaned dependencies (issue_id not in issues)", orphanedDepsIssue)
|
|
}
|
|
|
|
// Check for orphaned dependencies (depends_on_id not in issues)
|
|
var orphanedDepsDependsOn int
|
|
err = db.QueryRow(`
|
|
SELECT COUNT(*)
|
|
FROM dependencies d
|
|
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = d.depends_on_id)
|
|
`).Scan(&orphanedDepsDependsOn)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check orphaned dependencies (depends_on_id): %w", err)
|
|
}
|
|
if orphanedDepsDependsOn > 0 {
|
|
return fmt.Errorf("found %d orphaned dependencies (depends_on_id not in issues)", orphanedDepsDependsOn)
|
|
}
|
|
|
|
// Check for orphaned labels (issue_id not in issues)
|
|
var orphanedLabels int
|
|
err = db.QueryRow(`
|
|
SELECT COUNT(*)
|
|
FROM labels l
|
|
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = l.issue_id)
|
|
`).Scan(&orphanedLabels)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check orphaned labels: %w", err)
|
|
}
|
|
if orphanedLabels > 0 {
|
|
return fmt.Errorf("found %d orphaned labels (issue_id not in issues)", orphanedLabels)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkIssueCount ensures issue count doesn't decrease unexpectedly
|
|
func checkIssueCount(db *sql.DB, snapshot *Snapshot) error {
|
|
var currentCount int
|
|
err := db.QueryRow("SELECT COUNT(*) FROM issues").Scan(¤tCount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to count issues: %w", err)
|
|
}
|
|
|
|
if currentCount < snapshot.IssueCount {
|
|
return fmt.Errorf("issue count decreased from %d to %d (potential data loss)", snapshot.IssueCount, currentCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetInvariantNames returns the names of all registered invariants (for testing/inspection)
|
|
func GetInvariantNames() []string {
|
|
names := make([]string, len(invariants))
|
|
for i, inv := range invariants {
|
|
names[i] = inv.Name
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|