Merge branch 'main' of github.com:steveyegge/beads
This commit is contained in:
File diff suppressed because one or more lines are too long
18
AGENTS.md
18
AGENTS.md
@@ -226,8 +226,26 @@ bd merge bd-42 bd-43 --into bd-41 --dry-run # Preview merge
|
|||||||
bd migrate # Detect and migrate old databases
|
bd migrate # Detect and migrate old databases
|
||||||
bd migrate --dry-run # Preview migration
|
bd migrate --dry-run # Preview migration
|
||||||
bd migrate --cleanup --yes # Migrate and remove old files
|
bd migrate --cleanup --yes # Migrate and remove old files
|
||||||
|
|
||||||
|
# AI-supervised migration (check before running bd migrate)
|
||||||
|
bd migrate --inspect --json # Show migration plan for AI agents
|
||||||
|
bd info --schema --json # Get schema, tables, config, sample IDs
|
||||||
|
|
||||||
|
# Workflow: AI agents should inspect first, then migrate
|
||||||
|
# 1. Run --inspect to see pending migrations and warnings
|
||||||
|
# 2. Check for missing_config (like issue_prefix)
|
||||||
|
# 3. Review invariants_to_check for safety guarantees
|
||||||
|
# 4. If warnings exist, fix config issues first
|
||||||
|
# 5. Then run bd migrate safely
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Migration safety:** The system verifies data integrity invariants after migrations:
|
||||||
|
- **required_config_present**: Ensures issue_prefix and schema_version are set
|
||||||
|
- **foreign_keys_valid**: No orphaned dependencies or labels
|
||||||
|
- **issue_count_stable**: Issue count doesn't decrease unexpectedly
|
||||||
|
|
||||||
|
These invariants prevent data loss and would have caught issues like GH #201 (missing issue_prefix after migration).
|
||||||
|
|
||||||
### Managing Daemons
|
### Managing Daemons
|
||||||
|
|
||||||
bd runs a background daemon per workspace for auto-sync and RPC operations. Use `bd daemons` to manage multiple daemons:
|
bd runs a background daemon per workspace for auto-sync and RPC operations. Use `bd daemons` to manage multiple daemons:
|
||||||
|
|||||||
@@ -129,7 +129,13 @@ You can use project-specific databases:
|
|||||||
After upgrading bd, use `bd migrate` to check for and migrate old database files:
|
After upgrading bd, use `bd migrate` to check for and migrate old database files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check for migration opportunities
|
# Inspect migration plan (AI agents)
|
||||||
|
./bd migrate --inspect --json
|
||||||
|
|
||||||
|
# Check schema and config
|
||||||
|
./bd info --schema --json
|
||||||
|
|
||||||
|
# Preview migration changes
|
||||||
./bd migrate --dry-run
|
./bd migrate --dry-run
|
||||||
|
|
||||||
# Migrate old databases to beads.db
|
# Migrate old databases to beads.db
|
||||||
@@ -139,6 +145,8 @@ After upgrading bd, use `bd migrate` to check for and migrate old database files
|
|||||||
./bd migrate --cleanup --yes
|
./bd migrate --cleanup --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**AI agents:** Use `--inspect` to analyze migration safety before running. The system verifies required config keys and data integrity invariants.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Add labels: `./bd create "Task" -l "backend,urgent"`
|
- Add labels: `./bd create "Task" -l "backend,urgent"`
|
||||||
|
|||||||
@@ -259,6 +259,12 @@ Hash IDs use **birthday paradox probability** to determine length:
|
|||||||
**Existing databases continue to work** - no forced migration. Run `bd migrate` when ready:
|
**Existing databases continue to work** - no forced migration. Run `bd migrate` when ready:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Inspect migration plan (for AI agents)
|
||||||
|
bd migrate --inspect --json
|
||||||
|
|
||||||
|
# Check schema and config state
|
||||||
|
bd info --schema --json
|
||||||
|
|
||||||
# Preview migration
|
# Preview migration
|
||||||
bd migrate --dry-run
|
bd migrate --dry-run
|
||||||
|
|
||||||
@@ -269,6 +275,8 @@ bd migrate
|
|||||||
bd info
|
bd info
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**AI-supervised migrations:** The `--inspect` flag provides migration plan analysis for AI agents. The system verifies data integrity invariants (required config keys, foreign key constraints, issue counts) before committing migrations.
|
||||||
|
|
||||||
**Note:** Hash IDs require schema version 9+. The `bd migrate` command detects old schemas and upgrades automatically.
|
**Note:** Hash IDs require schema version 9+. The `bd migrate` command detects old schemas and upgrades automatically.
|
||||||
|
|
||||||
### Hierarchical Child IDs
|
### Hierarchical Child IDs
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
@@ -21,11 +22,15 @@ or daemon connection. It shows:
|
|||||||
- Daemon connection status (daemon or direct mode)
|
- Daemon connection status (daemon or direct mode)
|
||||||
- If using daemon: socket path, health status, version
|
- If using daemon: socket path, health status, version
|
||||||
- Database statistics (issue count)
|
- Database statistics (issue count)
|
||||||
|
- Schema information (with --schema flag)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd info
|
bd info
|
||||||
bd info --json`,
|
bd info --json
|
||||||
|
bd info --schema --json`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
schemaFlag, _ := cmd.Flags().GetBool("schema")
|
||||||
|
|
||||||
// Get database path (absolute)
|
// Get database path (absolute)
|
||||||
absDBPath, err := filepath.Abs(dbPath)
|
absDBPath, err := filepath.Abs(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,6 +86,55 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add schema information if requested
|
||||||
|
if schemaFlag && store != nil {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get schema version
|
||||||
|
schemaVersion, err := store.GetMetadata(ctx, "bd_version")
|
||||||
|
if err != nil {
|
||||||
|
schemaVersion = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tables
|
||||||
|
tables := []string{"issues", "dependencies", "labels", "config", "metadata"}
|
||||||
|
|
||||||
|
// Get config
|
||||||
|
configMap := make(map[string]string)
|
||||||
|
prefix, _ := store.GetConfig(ctx, "issue_prefix")
|
||||||
|
if prefix != "" {
|
||||||
|
configMap["issue_prefix"] = prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sample issue IDs
|
||||||
|
filter := types.IssueFilter{}
|
||||||
|
issues, err := store.SearchIssues(ctx, "", filter)
|
||||||
|
sampleIDs := []string{}
|
||||||
|
detectedPrefix := ""
|
||||||
|
if err == nil && len(issues) > 0 {
|
||||||
|
// Get first 3 issue IDs as samples
|
||||||
|
maxSamples := 3
|
||||||
|
if len(issues) < maxSamples {
|
||||||
|
maxSamples = len(issues)
|
||||||
|
}
|
||||||
|
for i := 0; i < maxSamples; i++ {
|
||||||
|
sampleIDs = append(sampleIDs, issues[i].ID)
|
||||||
|
}
|
||||||
|
// Detect prefix from first issue
|
||||||
|
if len(issues) > 0 {
|
||||||
|
detectedPrefix = extractPrefix(issues[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info["schema"] = map[string]interface{}{
|
||||||
|
"tables": tables,
|
||||||
|
"schema_version": schemaVersion,
|
||||||
|
"config": configMap,
|
||||||
|
"sample_issue_ids": sampleIDs,
|
||||||
|
"detected_prefix": detectedPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// JSON output
|
// JSON output
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
outputJSON(info)
|
outputJSON(info)
|
||||||
@@ -125,10 +179,38 @@ Examples:
|
|||||||
fmt.Printf("\nIssue Count: %d\n", count)
|
fmt.Printf("\nIssue Count: %d\n", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show schema information if requested
|
||||||
|
if schemaFlag {
|
||||||
|
if schemaInfo, ok := info["schema"].(map[string]interface{}); ok {
|
||||||
|
fmt.Println("\nSchema Information:")
|
||||||
|
fmt.Printf(" Tables: %v\n", schemaInfo["tables"])
|
||||||
|
if version, ok := schemaInfo["schema_version"].(string); ok {
|
||||||
|
fmt.Printf(" Schema Version: %s\n", version)
|
||||||
|
}
|
||||||
|
if prefix, ok := schemaInfo["detected_prefix"].(string); ok && prefix != "" {
|
||||||
|
fmt.Printf(" Detected Prefix: %s\n", prefix)
|
||||||
|
}
|
||||||
|
if samples, ok := schemaInfo["sample_issue_ids"].([]string); ok && len(samples) > 0 {
|
||||||
|
fmt.Printf(" Sample Issues: %v\n", samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractPrefix extracts the prefix from an issue ID (e.g., "bd-123" -> "bd")
|
||||||
|
func extractPrefix(issueID string) string {
|
||||||
|
parts := strings.Split(issueID, "-")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
infoCmd.Flags().Bool("schema", false, "Include schema information in output")
|
||||||
|
infoCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
||||||
rootCmd.AddCommand(infoCmd)
|
rootCmd.AddCommand(infoCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ This command:
|
|||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
updateRepoID, _ := cmd.Flags().GetBool("update-repo-id")
|
updateRepoID, _ := cmd.Flags().GetBool("update-repo-id")
|
||||||
toHashIDs, _ := cmd.Flags().GetBool("to-hash-ids")
|
toHashIDs, _ := cmd.Flags().GetBool("to-hash-ids")
|
||||||
|
inspect, _ := cmd.Flags().GetBool("inspect")
|
||||||
|
|
||||||
// Handle --update-repo-id first
|
// Handle --update-repo-id first
|
||||||
if updateRepoID {
|
if updateRepoID {
|
||||||
@@ -44,6 +45,12 @@ This command:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle --inspect flag (show migration plan for AI agents)
|
||||||
|
if inspect {
|
||||||
|
handleInspect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Find .beads directory
|
// Find .beads directory
|
||||||
beadsDir := findBeadsDir()
|
beadsDir := findBeadsDir()
|
||||||
if beadsDir == "" {
|
if beadsDir == "" {
|
||||||
@@ -695,12 +702,196 @@ func cleanupWALFiles(dbPath string) {
|
|||||||
_ = os.Remove(shmPath)
|
_ = os.Remove(shmPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleInspect shows migration plan and database state for AI agent analysis
|
||||||
|
func handleInspect() {
|
||||||
|
// Find .beads directory
|
||||||
|
beadsDir := findBeadsDir()
|
||||||
|
if beadsDir == "" {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "no_beads_directory",
|
||||||
|
"message": "No .beads directory found. Run 'bd init' first.",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n")
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
cfg, err := loadOrCreateConfig(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "config_load_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to load config: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if database exists (don't create it)
|
||||||
|
targetPath := cfg.DatabasePath(beadsDir)
|
||||||
|
dbExists := false
|
||||||
|
if _, err := os.Stat(targetPath); err == nil {
|
||||||
|
dbExists = true
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
// Stat error (not just "doesn't exist")
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "database_stat_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to check database: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If database doesn't exist, return inspection with defaults
|
||||||
|
if !dbExists {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"registered_migrations": sqlite.ListMigrations(),
|
||||||
|
"current_state": map[string]interface{}{
|
||||||
|
"schema_version": "missing",
|
||||||
|
"issue_count": 0,
|
||||||
|
"config": map[string]string{},
|
||||||
|
"missing_config": []string{},
|
||||||
|
"db_exists": false,
|
||||||
|
},
|
||||||
|
"warnings": []string{"Database does not exist - run 'bd init' first"},
|
||||||
|
"invariants_to_check": sqlite.GetInvariantNames(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(result)
|
||||||
|
} else {
|
||||||
|
fmt.Println("\nMigration Inspection")
|
||||||
|
fmt.Println("====================")
|
||||||
|
fmt.Println("Database: missing")
|
||||||
|
fmt.Println("\n⚠ Database does not exist - run 'bd init' first")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open database in read-only mode for inspection
|
||||||
|
store, err := sqlite.New(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"error": "database_open_failed",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get current schema version
|
||||||
|
schemaVersion, err := store.GetMetadata(ctx, "bd_version")
|
||||||
|
if err != nil {
|
||||||
|
schemaVersion = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issue count (use efficient COUNT query)
|
||||||
|
issueCount := 0
|
||||||
|
if stats, err := store.GetStatistics(ctx); err == nil {
|
||||||
|
issueCount = stats.TotalIssues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get config
|
||||||
|
configMap := make(map[string]string)
|
||||||
|
prefix, _ := store.GetConfig(ctx, "issue_prefix")
|
||||||
|
if prefix != "" {
|
||||||
|
configMap["issue_prefix"] = prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect missing config
|
||||||
|
missingConfig := []string{}
|
||||||
|
if issueCount > 0 && prefix == "" {
|
||||||
|
missingConfig = append(missingConfig, "issue_prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get registered migrations (all migrations are idempotent and run on every open)
|
||||||
|
registeredMigrations := sqlite.ListMigrations()
|
||||||
|
|
||||||
|
// Build invariants list
|
||||||
|
invariantNames := sqlite.GetInvariantNames()
|
||||||
|
|
||||||
|
// Generate warnings
|
||||||
|
warnings := []string{}
|
||||||
|
if issueCount > 0 && prefix == "" {
|
||||||
|
// Detect prefix from first issue (efficient query for just 1 issue)
|
||||||
|
detectedPrefix := ""
|
||||||
|
if issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}); err == nil && len(issues) > 0 {
|
||||||
|
detectedPrefix = utils.ExtractIssuePrefix(issues[0].ID)
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("issue_prefix config not set - may break commands after migration (detected: %s)", detectedPrefix))
|
||||||
|
}
|
||||||
|
if schemaVersion != Version {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("schema version mismatch (current: %s, expected: %s)", schemaVersion, Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output result
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"registered_migrations": registeredMigrations,
|
||||||
|
"current_state": map[string]interface{}{
|
||||||
|
"schema_version": schemaVersion,
|
||||||
|
"issue_count": issueCount,
|
||||||
|
"config": configMap,
|
||||||
|
"missing_config": missingConfig,
|
||||||
|
"db_exists": true,
|
||||||
|
},
|
||||||
|
"warnings": warnings,
|
||||||
|
"invariants_to_check": invariantNames,
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(result)
|
||||||
|
} else {
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Println("\nMigration Inspection")
|
||||||
|
fmt.Println("====================")
|
||||||
|
fmt.Printf("Schema Version: %s\n", schemaVersion)
|
||||||
|
fmt.Printf("Issue Count: %d\n", issueCount)
|
||||||
|
fmt.Printf("Registered Migrations: %d\n", len(registeredMigrations))
|
||||||
|
|
||||||
|
if len(warnings) > 0 {
|
||||||
|
fmt.Println("\nWarnings:")
|
||||||
|
for _, w := range warnings {
|
||||||
|
fmt.Printf(" ⚠ %s\n", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missingConfig) > 0 {
|
||||||
|
fmt.Println("\nMissing Config:")
|
||||||
|
for _, k := range missingConfig {
|
||||||
|
fmt.Printf(" - %s\n", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nInvariants to Check: %d\n", len(invariantNames))
|
||||||
|
for _, inv := range invariantNames {
|
||||||
|
fmt.Printf(" ✓ %s\n", inv)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts")
|
migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts")
|
||||||
migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration")
|
migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration")
|
||||||
migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
|
migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
|
||||||
migrateCmd.Flags().Bool("update-repo-id", false, "Update repository ID (use after changing git remote)")
|
migrateCmd.Flags().Bool("update-repo-id", false, "Update repository ID (use after changing git remote)")
|
||||||
migrateCmd.Flags().Bool("to-hash-ids", false, "Migrate sequential IDs to hash-based IDs")
|
migrateCmd.Flags().Bool("to-hash-ids", false, "Migrate sequential IDs to hash-based IDs")
|
||||||
|
migrateCmd.Flags().Bool("inspect", false, "Show migration plan and database state for AI agent analysis")
|
||||||
migrateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output migration statistics in JSON format")
|
migrateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output migration statistics in JSON format")
|
||||||
rootCmd.AddCommand(migrateCmd)
|
rootCmd.AddCommand(migrateCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ class BdClientBase(ABC):
|
|||||||
"""Initialize a new beads database."""
|
"""Initialize a new beads database."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def inspect_migration(self) -> dict:
|
||||||
|
"""Get migration plan and database state for agent analysis."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_schema_info(self) -> dict:
|
||||||
|
"""Get current database schema for inspection."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BdCliClient(BdClientBase):
|
class BdCliClient(BdClientBase):
|
||||||
"""Client for calling bd CLI commands and parsing JSON output."""
|
"""Client for calling bd CLI commands and parsing JSON output."""
|
||||||
@@ -575,6 +585,28 @@ class BdCliClient(BdClientBase):
|
|||||||
|
|
||||||
return [BlockedIssue.model_validate(issue) for issue in data]
|
return [BlockedIssue.model_validate(issue) for issue in data]
|
||||||
|
|
||||||
|
async def inspect_migration(self) -> dict:
|
||||||
|
"""Get migration plan and database state for agent analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Migration plan dict with registered_migrations, warnings, etc.
|
||||||
|
"""
|
||||||
|
data = await self._run_command("migrate", "--inspect")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise BdCommandError("Invalid response for inspect_migration")
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_schema_info(self) -> dict:
|
||||||
|
"""Get current database schema for inspection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema info dict with tables, version, config, sample IDs, etc.
|
||||||
|
"""
|
||||||
|
data = await self._run_command("info", "--schema")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise BdCommandError("Invalid response for get_schema_info")
|
||||||
|
return data
|
||||||
|
|
||||||
async def init(self, params: InitParams | None = None) -> str:
|
async def init(self, params: InitParams | None = None) -> str:
|
||||||
"""Initialize bd in current directory.
|
"""Initialize bd in current directory.
|
||||||
|
|
||||||
|
|||||||
@@ -430,6 +430,28 @@ class BdDaemonClient(BdClientBase):
|
|||||||
# This is a placeholder for when it's added
|
# This is a placeholder for when it's added
|
||||||
raise NotImplementedError("Blocked operation not yet supported via daemon")
|
raise NotImplementedError("Blocked operation not yet supported via daemon")
|
||||||
|
|
||||||
|
async def inspect_migration(self) -> dict:
|
||||||
|
"""Get migration plan and database state for agent analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Migration plan dict with registered_migrations, warnings, etc.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This falls back to CLI since migrations are rare operations
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("inspect_migration not supported via daemon - use CLI client")
|
||||||
|
|
||||||
|
async def get_schema_info(self) -> dict:
|
||||||
|
"""Get current database schema for inspection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema info dict with tables, version, config, sample IDs, etc.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This falls back to CLI since schema inspection is a rare operation
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("get_schema_info not supported via daemon - use CLI client")
|
||||||
|
|
||||||
async def add_dependency(self, params: AddDependencyParams) -> None:
|
async def add_dependency(self, params: AddDependencyParams) -> None:
|
||||||
"""Add a dependency between issues.
|
"""Add a dependency between issues.
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from beads_mcp.tools import (
|
|||||||
beads_blocked,
|
beads_blocked,
|
||||||
beads_close_issue,
|
beads_close_issue,
|
||||||
beads_create_issue,
|
beads_create_issue,
|
||||||
|
beads_get_schema_info,
|
||||||
beads_init,
|
beads_init,
|
||||||
|
beads_inspect_migration,
|
||||||
beads_list_issues,
|
beads_list_issues,
|
||||||
beads_quickstart,
|
beads_quickstart,
|
||||||
beads_ready_work,
|
beads_ready_work,
|
||||||
@@ -512,6 +514,39 @@ async def debug_env(workspace_root: str | None = None) -> str:
|
|||||||
return "".join(info)
|
return "".join(info)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="inspect_migration",
|
||||||
|
description="Get migration plan and database state for agent analysis.",
|
||||||
|
)
|
||||||
|
@with_workspace
|
||||||
|
async def inspect_migration(workspace_root: str | None = None) -> dict:
|
||||||
|
"""Get migration plan and database state for agent analysis.
|
||||||
|
|
||||||
|
AI agents should:
|
||||||
|
1. Review registered_migrations to understand what will run
|
||||||
|
2. Check warnings array for issues (missing config, version mismatch)
|
||||||
|
3. Verify missing_config is empty before migrating
|
||||||
|
4. Check invariants_to_check to understand safety guarantees
|
||||||
|
|
||||||
|
Returns migration plan, current db state, warnings, and invariants.
|
||||||
|
"""
|
||||||
|
return await beads_inspect_migration()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="get_schema_info",
|
||||||
|
description="Get current database schema for inspection.",
|
||||||
|
)
|
||||||
|
@with_workspace
|
||||||
|
async def get_schema_info(workspace_root: str | None = None) -> dict:
|
||||||
|
"""Get current database schema for inspection.
|
||||||
|
|
||||||
|
Returns tables, schema version, config, sample issue IDs, and detected prefix.
|
||||||
|
Useful for verifying database state before migrations.
|
||||||
|
"""
|
||||||
|
return await beads_get_schema_info()
|
||||||
|
|
||||||
|
|
||||||
async def async_main() -> None:
|
async def async_main() -> None:
|
||||||
"""Async entry point for the MCP server."""
|
"""Async entry point for the MCP server."""
|
||||||
await mcp.run_async(transport="stdio")
|
await mcp.run_async(transport="stdio")
|
||||||
|
|||||||
@@ -453,6 +453,31 @@ async def beads_blocked() -> list[BlockedIssue]:
|
|||||||
return await client.blocked()
|
return await client.blocked()
|
||||||
|
|
||||||
|
|
||||||
|
async def beads_inspect_migration() -> dict:
|
||||||
|
"""Get migration plan and database state for agent analysis.
|
||||||
|
|
||||||
|
AI agents should:
|
||||||
|
1. Review registered_migrations to understand what will run
|
||||||
|
2. Check warnings array for issues (missing config, version mismatch)
|
||||||
|
3. Verify missing_config is empty before migrating
|
||||||
|
4. Check invariants_to_check to understand safety guarantees
|
||||||
|
|
||||||
|
Returns migration plan, current db state, warnings, and invariants.
|
||||||
|
"""
|
||||||
|
client = await _get_client()
|
||||||
|
return await client.inspect_migration()
|
||||||
|
|
||||||
|
|
||||||
|
async def beads_get_schema_info() -> dict:
|
||||||
|
"""Get current database schema for inspection.
|
||||||
|
|
||||||
|
Returns tables, schema version, config, sample issue IDs, and detected prefix.
|
||||||
|
Useful for verifying database state before migrations.
|
||||||
|
"""
|
||||||
|
client = await _get_client()
|
||||||
|
return await client.get_schema_info()
|
||||||
|
|
||||||
|
|
||||||
async def beads_init(
|
async def beads_init(
|
||||||
prefix: Annotated[str | None, "Issue prefix (e.g., 'myproject' for myproject-1, myproject-2)"] = None,
|
prefix: Annotated[str | None, "Issue prefix (e.g., 'myproject' for myproject-1, myproject-2)"] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
204
internal/storage/sqlite/migration_invariants.go
Normal file
204
internal/storage/sqlite/migration_invariants.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
260
internal/storage/sqlite/migration_invariants_test.go
Normal file
260
internal/storage/sqlite/migration_invariants_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCaptureSnapshot(t *testing.T) {
|
||||||
|
db := setupInvariantTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Create some test data
|
||||||
|
_, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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 test dependency: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`INSERT INTO labels (issue_id, label) VALUES ('test-1', 'test-label')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert test label: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := captureSnapshot(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("captureSnapshot failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.IssueCount != 1 {
|
||||||
|
t.Errorf("expected IssueCount=1, got %d", snapshot.IssueCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.DependencyCount != 1 {
|
||||||
|
t.Errorf("expected DependencyCount=1, got %d", snapshot.DependencyCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.LabelCount != 1 {
|
||||||
|
t.Errorf("expected LabelCount=1, got %d", snapshot.LabelCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRequiredConfig(t *testing.T) {
|
||||||
|
db := setupInvariantTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Test with no issues - should pass even without issue_prefix
|
||||||
|
snapshot := &Snapshot{IssueCount: 0}
|
||||||
|
err := checkRequiredConfig(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error with 0 issues, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an issue to the database
|
||||||
|
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete issue_prefix config
|
||||||
|
_, err = db.Exec(`DELETE FROM config WHERE key = 'issue_prefix'`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail now that we have an issue but no prefix
|
||||||
|
err = checkRequiredConfig(db, snapshot)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing issue_prefix with issues, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add required config back
|
||||||
|
_, err = db.Exec(`INSERT INTO config (key, value) VALUES ('issue_prefix', 'test')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with required config present
|
||||||
|
err = checkRequiredConfig(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error with issue_prefix set, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckForeignKeys(t *testing.T) {
|
||||||
|
db := setupInvariantTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
snapshot := &Snapshot{}
|
||||||
|
|
||||||
|
// Test with no data - should pass
|
||||||
|
err := checkForeignKeys(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error with empty db, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add valid dependency
|
||||||
|
_, 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 dependency: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should pass with valid foreign keys
|
||||||
|
err = checkForeignKeys(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error with valid dependencies, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually create orphaned dependency (bypassing FK constraints for testing)
|
||||||
|
_, err = db.Exec(`PRAGMA foreign_keys = OFF`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to disable foreign keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`PRAGMA foreign_keys = ON`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to enable foreign keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail with orphaned dependency
|
||||||
|
err = checkForeignKeys(db, snapshot)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for orphaned dependency, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckIssueCount(t *testing.T) {
|
||||||
|
db := setupInvariantTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Create initial 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := captureSnapshot(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("captureSnapshot failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same count - should pass
|
||||||
|
err = checkIssueCount(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error with same count, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an issue - should pass (count increased)
|
||||||
|
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-2', 'Test Issue 2')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert second issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkIssueCount(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error with increased count, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete both issues to simulate data loss
|
||||||
|
_, err = db.Exec(`DELETE FROM issues`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete issues: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail when count decreased
|
||||||
|
err = checkIssueCount(db, snapshot)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for decreased issue count, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyInvariants(t *testing.T) {
|
||||||
|
db := setupInvariantTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
snapshot, err := captureSnapshot(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("captureSnapshot failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All invariants should pass with empty database
|
||||||
|
err = verifyInvariants(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no errors with empty db, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an issue (which requires issue_prefix)
|
||||||
|
_, err = db.Exec(`INSERT INTO issues (id, title) VALUES ('test-1', 'Test Issue')`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture new snapshot with issue
|
||||||
|
snapshot, err = captureSnapshot(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("captureSnapshot failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still pass (issue_prefix is set by newTestStore)
|
||||||
|
err = verifyInvariants(db, snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no errors with issue and prefix, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove required config to trigger failure
|
||||||
|
_, err = db.Exec(`DELETE FROM config WHERE key = 'issue_prefix'`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to delete config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = verifyInvariants(db, snapshot)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when issue_prefix missing with issues, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInvariantNames(t *testing.T) {
|
||||||
|
names := GetInvariantNames()
|
||||||
|
|
||||||
|
expectedNames := []string{
|
||||||
|
"foreign_keys_valid",
|
||||||
|
"issue_count_stable",
|
||||||
|
"required_config_present",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(names) != len(expectedNames) {
|
||||||
|
t.Errorf("expected %d invariants, got %d", len(expectedNames), len(names))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, name := range names {
|
||||||
|
if name != expectedNames[i] {
|
||||||
|
t.Errorf("expected invariant[%d]=%s, got %s", i, expectedNames[i], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupInvariantTestDB creates an in-memory test database with schema
|
||||||
|
func setupInvariantTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
store := newTestStore(t, ":memory:")
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
|
||||||
|
// Return the underlying database connection
|
||||||
|
return store.db
|
||||||
|
}
|
||||||
@@ -29,13 +29,66 @@ var migrations = []Migration{
|
|||||||
{"content_hash_column", migrateContentHashColumn},
|
{"content_hash_column", migrateContentHashColumn},
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunMigrations executes all registered migrations in order
|
// MigrationInfo contains metadata about a migration for inspection
|
||||||
|
type MigrationInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMigrations returns list of all registered migrations with descriptions
|
||||||
|
// Note: This returns ALL registered migrations, not just pending ones (all are idempotent)
|
||||||
|
func ListMigrations() []MigrationInfo {
|
||||||
|
result := make([]MigrationInfo, len(migrations))
|
||||||
|
for i, m := range migrations {
|
||||||
|
result[i] = MigrationInfo{
|
||||||
|
Name: m.Name,
|
||||||
|
Description: getMigrationDescription(m.Name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMigrationDescription returns a human-readable description for a migration
|
||||||
|
func getMigrationDescription(name string) string {
|
||||||
|
descriptions := map[string]string{
|
||||||
|
"dirty_issues_table": "Adds dirty_issues table for auto-export tracking",
|
||||||
|
"external_ref_column": "Adds external_ref column to issues table",
|
||||||
|
"composite_indexes": "Adds composite indexes for better query performance",
|
||||||
|
"closed_at_constraint": "Adds constraint ensuring closed issues have closed_at timestamp",
|
||||||
|
"compaction_columns": "Adds compaction tracking columns (compacted_at, compacted_at_commit)",
|
||||||
|
"snapshots_table": "Adds snapshots table for issue history",
|
||||||
|
"compaction_config": "Adds config entries for compaction",
|
||||||
|
"compacted_at_commit_column": "Adds compacted_at_commit to snapshots table",
|
||||||
|
"export_hashes_table": "Adds export_hashes table for idempotent exports",
|
||||||
|
"content_hash_column": "Adds content_hash column for collision resolution",
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc, ok := descriptions[name]; ok {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
return "Unknown migration"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations executes all registered migrations in order with invariant checking
|
||||||
func RunMigrations(db *sql.DB) error {
|
func RunMigrations(db *sql.DB) error {
|
||||||
|
// Capture pre-migration snapshot for validation
|
||||||
|
snapshot, err := captureSnapshot(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to capture pre-migration snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations (they are already idempotent)
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
if err := migration.Func(db); err != nil {
|
if err := migration.Func(db); err != nil {
|
||||||
return fmt.Errorf("migration %s failed: %w", migration.Name, err)
|
return fmt.Errorf("migration %s failed: %w", migration.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify invariants after migrations complete
|
||||||
|
if err := verifyInvariants(db, snapshot); err != nil {
|
||||||
|
return fmt.Errorf("post-migration validation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user