feat(migrate): add SQLite to Dolt migration tooling (hq-ew1mbr.6)
Add `bd migrate --to-dolt` and `bd migrate --to-sqlite` commands for migrating between SQLite and Dolt storage backends. Features: - `--to-dolt`: Migrate from SQLite to Dolt backend - Creates backup of SQLite database before migration - Imports all issues, labels, and dependencies - Updates metadata.json to use Dolt backend - Preserves JSONL export configuration - `--to-sqlite`: Escape hatch to migrate back to SQLite - Exports all data from Dolt to new SQLite database - Updates metadata.json to use SQLite backend Both commands support: - `--dry-run` flag to preview changes - `--yes` flag for automated/scripted usage - `--json` flag for machine-readable output - Non-cgo stub for builds without CGO support This implements Part 7 (Migration Tooling) of DOLT-STORAGE-DESIGN.md. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
e74cab8c6d
commit
4e3e9d1441
@@ -33,6 +33,10 @@ Without subcommand, detects and migrates database schema to current version:
|
||||
- Updates schema version metadata
|
||||
- Removes stale databases (with confirmation)
|
||||
|
||||
Backend migration flags:
|
||||
--to-dolt Migrate from SQLite to Dolt backend
|
||||
--to-sqlite Migrate from Dolt to SQLite backend (escape hatch)
|
||||
|
||||
Subcommands:
|
||||
hash-ids Migrate sequential IDs to hash-based IDs (legacy)
|
||||
issues Move issues between repositories
|
||||
@@ -62,6 +66,20 @@ Subcommands:
|
||||
return
|
||||
}
|
||||
|
||||
// Handle --to-dolt flag (SQLite to Dolt migration)
|
||||
toDolt, _ := cmd.Flags().GetBool("to-dolt")
|
||||
if toDolt {
|
||||
handleToDoltMigration(dryRun, autoYes)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle --to-sqlite flag (Dolt to SQLite migration, escape hatch)
|
||||
toSQLite, _ := cmd.Flags().GetBool("to-sqlite")
|
||||
if toSQLite {
|
||||
handleToSQLiteMigration(dryRun, autoYes)
|
||||
return
|
||||
}
|
||||
|
||||
// Find .beads directory
|
||||
beadsDir := beads.FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
@@ -974,6 +992,8 @@ func init() {
|
||||
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("inspect", false, "Show migration plan and database state for AI agent analysis")
|
||||
migrateCmd.Flags().Bool("to-dolt", false, "Migrate from SQLite to Dolt backend")
|
||||
migrateCmd.Flags().Bool("to-sqlite", false, "Migrate from Dolt to SQLite backend (escape hatch)")
|
||||
migrateCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output migration statistics in JSON format")
|
||||
rootCmd.AddCommand(migrateCmd)
|
||||
}
|
||||
|
||||
897
cmd/bd/migrate_dolt.go
Normal file
897
cmd/bd/migrate_dolt.go
Normal file
@@ -0,0 +1,897 @@
|
||||
//go:build cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/beads"
|
||||
"github.com/steveyegge/beads/internal/configfile"
|
||||
"github.com/steveyegge/beads/internal/storage"
|
||||
"github.com/steveyegge/beads/internal/storage/dolt"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
)
|
||||
|
||||
// handleToDolt migrates from SQLite to Dolt backend.
|
||||
// This implements Part 7 of DOLT-STORAGE-DESIGN.md:
|
||||
// 1. Creates Dolt database in `.beads/dolt/`
|
||||
// 2. Imports all issues from SQLite
|
||||
// 3. Updates `metadata.json` to use Dolt
|
||||
// 4. Keeps JSONL export enabled by default
|
||||
// 5. SQLite file can be deleted after verification
|
||||
func handleToDoltMigration(dryRun bool, autoYes bool) {
|
||||
// Find .beads directory
|
||||
beadsDir := beads.FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "no_beads_directory",
|
||||
"message": "No .beads directory found. Run 'bd init' first.",
|
||||
})
|
||||
} else {
|
||||
FatalErrorWithHint("no .beads directory found", "run 'bd init' to initialize bd")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load config
|
||||
cfg, err := configfile.Load(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)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = configfile.DefaultConfig()
|
||||
}
|
||||
|
||||
// Check if already using Dolt
|
||||
if cfg.GetBackend() == configfile.BackendDolt {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "noop",
|
||||
"message": "Already using Dolt backend",
|
||||
})
|
||||
} else {
|
||||
fmt.Printf("%s\n", ui.RenderPass("✓ Already using Dolt backend"))
|
||||
fmt.Println("No migration needed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Find SQLite database
|
||||
sqlitePath := cfg.DatabasePath(beadsDir)
|
||||
if _, err := os.Stat(sqlitePath); os.IsNotExist(err) {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "no_sqlite_database",
|
||||
"message": "No SQLite database found to migrate",
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no SQLite database found at %s\n", sqlitePath)
|
||||
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd first\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Dolt path
|
||||
doltPath := filepath.Join(beadsDir, "dolt")
|
||||
|
||||
// Check if Dolt directory already exists
|
||||
if _, err := os.Stat(doltPath); err == nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "dolt_exists",
|
||||
"message": fmt.Sprintf("Dolt directory already exists at %s", doltPath),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: Dolt directory already exists at %s\n", doltPath)
|
||||
fmt.Fprintf(os.Stderr, "Hint: remove it first if you want to re-migrate, or switch backend with 'bd config backend dolt'\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Open SQLite to count issues
|
||||
ctx := context.Background()
|
||||
sqliteStore, err := sqlite.NewReadOnly(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "sqlite_open_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open SQLite database: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get all issues from SQLite
|
||||
issues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
|
||||
if err != nil {
|
||||
_ = sqliteStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "fetch_issues_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to fetch issues: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get labels for each issue
|
||||
issueIDs := make([]string, len(issues))
|
||||
for i, issue := range issues {
|
||||
issueIDs[i] = issue.ID
|
||||
}
|
||||
labelsMap, err := sqliteStore.GetLabelsForIssues(ctx, issueIDs)
|
||||
if err != nil {
|
||||
_ = sqliteStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "fetch_labels_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to fetch labels: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get dependencies for each issue
|
||||
allDeps, err := sqliteStore.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
_ = sqliteStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "fetch_deps_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to fetch dependencies: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get config values
|
||||
prefix, _ := sqliteStore.GetConfig(ctx, "issue_prefix")
|
||||
|
||||
// Close SQLite - we have all the data we need
|
||||
_ = sqliteStore.Close()
|
||||
|
||||
// Assign labels and dependencies to issues
|
||||
for _, issue := range issues {
|
||||
if labels, ok := labelsMap[issue.ID]; ok {
|
||||
issue.Labels = labels
|
||||
}
|
||||
if deps, ok := allDeps[issue.ID]; ok {
|
||||
issue.Dependencies = deps
|
||||
}
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("SQLite to Dolt Migration\n")
|
||||
fmt.Printf("========================\n\n")
|
||||
fmt.Printf("Source: %s\n", sqlitePath)
|
||||
fmt.Printf("Target: %s\n", doltPath)
|
||||
fmt.Printf("Issues to migrate: %d\n", len(issues))
|
||||
if prefix != "" {
|
||||
fmt.Printf("Issue prefix: %s\n", prefix)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Dry run mode
|
||||
if dryRun {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"source": sqlitePath,
|
||||
"target": doltPath,
|
||||
"issue_count": len(issues),
|
||||
"prefix": prefix,
|
||||
"would_backup": true,
|
||||
})
|
||||
} else {
|
||||
fmt.Println("Dry run mode - no changes will be made")
|
||||
fmt.Println("Would perform:")
|
||||
fmt.Printf(" 1. Create backup of SQLite database\n")
|
||||
fmt.Printf(" 2. Create Dolt database at %s\n", doltPath)
|
||||
fmt.Printf(" 3. Import %d issues with labels and dependencies\n", len(issues))
|
||||
fmt.Printf(" 4. Update metadata.json to use Dolt backend\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prompt for confirmation
|
||||
if !autoYes && !jsonOutput {
|
||||
fmt.Printf("This will:\n")
|
||||
fmt.Printf(" 1. Create a backup of your SQLite database\n")
|
||||
fmt.Printf(" 2. Create a Dolt database and import all issues\n")
|
||||
fmt.Printf(" 3. Update metadata.json to use Dolt backend\n")
|
||||
fmt.Printf(" 4. Keep your SQLite database (can be deleted after verification)\n\n")
|
||||
fmt.Printf("Continue? [y/N] ")
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" {
|
||||
fmt.Println("Migration canceled")
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := strings.TrimSuffix(sqlitePath, ".db") + ".backup-pre-dolt-" + time.Now().Format("20060102-150405") + ".db"
|
||||
if err := copyFile(sqlitePath, backupPath); err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "backup_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create backup: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Created backup: %s", filepath.Base(backupPath))))
|
||||
}
|
||||
|
||||
// Create Dolt database
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Creating Dolt database...\n")
|
||||
}
|
||||
|
||||
doltStore, err := dolt.New(ctx, &dolt.Config{Path: doltPath})
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "dolt_create_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create Dolt database: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set issue prefix
|
||||
if prefix != "" {
|
||||
if err := doltStore.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
||||
_ = doltStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "set_prefix_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Import issues
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Importing %d issues...\n", len(issues))
|
||||
}
|
||||
|
||||
imported, skipped, err := importIssuesToDolt(ctx, doltStore, issues)
|
||||
if err != nil {
|
||||
_ = doltStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "import_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to import issues: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Commit the migration
|
||||
commitMsg := fmt.Sprintf("Migrate from SQLite: %d issues imported", imported)
|
||||
if err := doltStore.Commit(ctx, commitMsg); err != nil {
|
||||
// Non-fatal - data is still in the database
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s\n", ui.RenderWarn(fmt.Sprintf("Warning: failed to create Dolt commit: %v", err)))
|
||||
}
|
||||
}
|
||||
|
||||
_ = doltStore.Close()
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Imported %d issues (%d skipped)", imported, skipped)))
|
||||
}
|
||||
|
||||
// Update metadata.json
|
||||
cfg.Backend = configfile.BackendDolt
|
||||
cfg.Database = "dolt" // Use dolt directory
|
||||
if err := cfg.Save(beadsDir); err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "config_save_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to update metadata.json: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s\n", ui.RenderPass("✓ Updated metadata.json to use Dolt backend"))
|
||||
}
|
||||
|
||||
// Final status
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "success",
|
||||
"backend": "dolt",
|
||||
"issues_imported": imported,
|
||||
"issues_skipped": skipped,
|
||||
"backup_path": backupPath,
|
||||
"dolt_path": doltPath,
|
||||
})
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", ui.RenderPass("✓ Migration complete!"))
|
||||
fmt.Println()
|
||||
fmt.Printf("Your beads now use Dolt storage.\n")
|
||||
fmt.Printf("SQLite backup: %s\n", backupPath)
|
||||
fmt.Println()
|
||||
fmt.Println("Next steps:")
|
||||
fmt.Println(" • Verify data: bd list")
|
||||
fmt.Println(" • After verification, you can delete the old SQLite database:")
|
||||
fmt.Printf(" rm %s\n", sqlitePath)
|
||||
fmt.Println()
|
||||
fmt.Println("To switch back to SQLite: bd migrate --to-sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
// importIssuesToDolt imports issues to Dolt, returning (imported, skipped, error)
|
||||
func importIssuesToDolt(ctx context.Context, store *dolt.DoltStore, issues []*types.Issue) (int, int, error) {
|
||||
tx, err := store.UnderlyingDB().BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
seenIDs := make(map[string]bool)
|
||||
|
||||
for _, issue := range issues {
|
||||
// Skip duplicates
|
||||
if seenIDs[issue.ID] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seenIDs[issue.ID] = true
|
||||
|
||||
// Compute content hash if missing
|
||||
if issue.ContentHash == "" {
|
||||
issue.ContentHash = issue.ComputeContentHash()
|
||||
}
|
||||
|
||||
// Insert issue directly via SQL (bypass validation since we're migrating existing data)
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO issues (
|
||||
id, content_hash, title, description, design, acceptance_criteria, notes,
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, created_by, owner, updated_at, closed_at, external_ref,
|
||||
compaction_level, compacted_at, compacted_at_commit, original_size,
|
||||
deleted_at, deleted_by, delete_reason, original_type,
|
||||
sender, ephemeral, pinned, is_template, crystallizes,
|
||||
mol_type, work_type, quality_score, source_system, source_repo, close_reason,
|
||||
event_kind, actor, target, payload,
|
||||
await_type, await_id, timeout_ns, waiters,
|
||||
hook_bead, role_bead, agent_state, last_activity, role_type, rig,
|
||||
due_at, defer_until
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?
|
||||
)
|
||||
`,
|
||||
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes,
|
||||
issue.Status, issue.Priority, issue.IssueType, nullableString(issue.Assignee), nullableIntPtr(issue.EstimatedMinutes),
|
||||
issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt, issue.ClosedAt, nullableStringPtr(issue.ExternalRef),
|
||||
issue.CompactionLevel, issue.CompactedAt, nullableStringPtr(issue.CompactedAtCommit), nullableInt(issue.OriginalSize),
|
||||
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
|
||||
issue.Sender, issue.Ephemeral, issue.Pinned, issue.IsTemplate, issue.Crystallizes,
|
||||
issue.MolType, issue.WorkType, nullableFloat32Ptr(issue.QualityScore), issue.SourceSystem, issue.SourceRepo, issue.CloseReason,
|
||||
issue.EventKind, issue.Actor, issue.Target, issue.Payload,
|
||||
issue.AwaitType, issue.AwaitID, issue.Timeout.Nanoseconds(), formatWaiters(issue.Waiters),
|
||||
issue.HookBead, issue.RoleBead, issue.AgentState, issue.LastActivity, issue.RoleType, issue.Rig,
|
||||
issue.DueAt, issue.DeferUntil,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Duplicate entry") ||
|
||||
strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
return imported, skipped, fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
// Insert labels
|
||||
for _, label := range issue.Labels {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO labels (issue_id, label)
|
||||
VALUES (?, ?)
|
||||
`, issue.ID, label)
|
||||
if err != nil && !strings.Contains(err.Error(), "Duplicate entry") {
|
||||
return imported, skipped, fmt.Errorf("failed to insert label for %s: %w", issue.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
imported++
|
||||
}
|
||||
|
||||
// Import dependencies in a second pass (after all issues exist)
|
||||
for _, issue := range issues {
|
||||
for _, dep := range issue.Dependencies {
|
||||
// Check if both issues exist
|
||||
var exists int
|
||||
err := tx.QueryRowContext(ctx, "SELECT 1 FROM issues WHERE id = ?", dep.DependsOnID).Scan(&exists)
|
||||
if err != nil {
|
||||
// Target doesn't exist, skip dependency
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE type = type
|
||||
`, dep.IssueID, dep.DependsOnID, dep.Type, dep.CreatedBy, dep.CreatedAt)
|
||||
if err != nil && !strings.Contains(err.Error(), "Duplicate entry") {
|
||||
// Non-fatal for dependencies - log and continue
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return imported, skipped, fmt.Errorf("failed to commit: %w", err)
|
||||
}
|
||||
|
||||
return imported, skipped, nil
|
||||
}
|
||||
|
||||
// handleToSQLite migrates from Dolt to SQLite backend (escape hatch).
|
||||
func handleToSQLiteMigration(dryRun bool, autoYes bool) {
|
||||
// Find .beads directory
|
||||
beadsDir := beads.FindBeadsDir()
|
||||
if beadsDir == "" {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "no_beads_directory",
|
||||
"message": "No .beads directory found. Run 'bd init' first.",
|
||||
})
|
||||
} else {
|
||||
FatalErrorWithHint("no .beads directory found", "run 'bd init' to initialize bd")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load config
|
||||
cfg, err := configfile.Load(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)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = configfile.DefaultConfig()
|
||||
}
|
||||
|
||||
// Check if already using SQLite
|
||||
if cfg.GetBackend() == configfile.BackendSQLite {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "noop",
|
||||
"message": "Already using SQLite backend",
|
||||
})
|
||||
} else {
|
||||
fmt.Printf("%s\n", ui.RenderPass("✓ Already using SQLite backend"))
|
||||
fmt.Println("No migration needed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Find Dolt database
|
||||
doltPath := cfg.DatabasePath(beadsDir)
|
||||
if _, err := os.Stat(doltPath); os.IsNotExist(err) {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "no_dolt_database",
|
||||
"message": "No Dolt database found to migrate",
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no Dolt database found at %s\n", doltPath)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// SQLite path
|
||||
sqlitePath := filepath.Join(beadsDir, "beads.db")
|
||||
|
||||
// Check if SQLite database already exists
|
||||
if _, err := os.Stat(sqlitePath); err == nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "sqlite_exists",
|
||||
"message": fmt.Sprintf("SQLite database already exists at %s", sqlitePath),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: SQLite database already exists at %s\n", sqlitePath)
|
||||
fmt.Fprintf(os.Stderr, "Hint: remove it first if you want to re-migrate\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Open Dolt to count issues
|
||||
doltStore, err := dolt.New(ctx, &dolt.Config{Path: doltPath, ReadOnly: true})
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "dolt_open_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to open Dolt database: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get all issues from Dolt
|
||||
issues, err := doltStore.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
|
||||
if err != nil {
|
||||
_ = doltStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "fetch_issues_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to fetch issues: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get labels and dependencies for each issue
|
||||
issueIDs := make([]string, len(issues))
|
||||
for i, issue := range issues {
|
||||
issueIDs[i] = issue.ID
|
||||
}
|
||||
labelsMap, err := doltStore.GetLabelsForIssues(ctx, issueIDs)
|
||||
if err != nil {
|
||||
_ = doltStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "fetch_labels_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to fetch labels: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
allDeps, err := doltStore.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
_ = doltStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "fetch_deps_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to fetch dependencies: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get config values
|
||||
prefix, _ := doltStore.GetConfig(ctx, "issue_prefix")
|
||||
|
||||
_ = doltStore.Close()
|
||||
|
||||
// Assign labels and dependencies to issues
|
||||
for _, issue := range issues {
|
||||
if labels, ok := labelsMap[issue.ID]; ok {
|
||||
issue.Labels = labels
|
||||
}
|
||||
if deps, ok := allDeps[issue.ID]; ok {
|
||||
issue.Dependencies = deps
|
||||
}
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Dolt to SQLite Migration\n")
|
||||
fmt.Printf("========================\n\n")
|
||||
fmt.Printf("Source: %s\n", doltPath)
|
||||
fmt.Printf("Target: %s\n", sqlitePath)
|
||||
fmt.Printf("Issues to migrate: %d\n", len(issues))
|
||||
if prefix != "" {
|
||||
fmt.Printf("Issue prefix: %s\n", prefix)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Dry run mode
|
||||
if dryRun {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"source": doltPath,
|
||||
"target": sqlitePath,
|
||||
"issue_count": len(issues),
|
||||
"prefix": prefix,
|
||||
})
|
||||
} else {
|
||||
fmt.Println("Dry run mode - no changes will be made")
|
||||
fmt.Println("Would perform:")
|
||||
fmt.Printf(" 1. Create SQLite database at %s\n", sqlitePath)
|
||||
fmt.Printf(" 2. Import %d issues with labels and dependencies\n", len(issues))
|
||||
fmt.Printf(" 3. Update metadata.json to use SQLite backend\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prompt for confirmation
|
||||
if !autoYes && !jsonOutput {
|
||||
fmt.Printf("This will:\n")
|
||||
fmt.Printf(" 1. Create a SQLite database and import all issues\n")
|
||||
fmt.Printf(" 2. Update metadata.json to use SQLite backend\n")
|
||||
fmt.Printf(" 3. Keep your Dolt database (can be deleted after verification)\n\n")
|
||||
fmt.Printf("Continue? [y/N] ")
|
||||
var response string
|
||||
_, _ = fmt.Scanln(&response)
|
||||
if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" {
|
||||
fmt.Println("Migration canceled")
|
||||
return
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Create SQLite database
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Creating SQLite database...\n")
|
||||
}
|
||||
|
||||
sqliteStore, err := sqlite.New(ctx, sqlitePath)
|
||||
if err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "sqlite_create_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to create SQLite database: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set issue prefix
|
||||
if prefix != "" {
|
||||
if err := sqliteStore.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
||||
_ = sqliteStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "set_prefix_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Import issues
|
||||
if !jsonOutput {
|
||||
fmt.Printf("Importing %d issues...\n", len(issues))
|
||||
}
|
||||
|
||||
imported, skipped, err := importIssuesToSQLite(ctx, sqliteStore, issues)
|
||||
if err != nil {
|
||||
_ = sqliteStore.Close()
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "import_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to import issues: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
_ = sqliteStore.Close()
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s\n", ui.RenderPass(fmt.Sprintf("✓ Imported %d issues (%d skipped)", imported, skipped)))
|
||||
}
|
||||
|
||||
// Update metadata.json
|
||||
cfg.Backend = configfile.BackendSQLite
|
||||
cfg.Database = "beads.db"
|
||||
if err := cfg.Save(beadsDir); err != nil {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "config_save_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to update metadata.json: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s\n", ui.RenderPass("✓ Updated metadata.json to use SQLite backend"))
|
||||
}
|
||||
|
||||
// Final status
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "success",
|
||||
"backend": "sqlite",
|
||||
"issues_imported": imported,
|
||||
"issues_skipped": skipped,
|
||||
"sqlite_path": sqlitePath,
|
||||
})
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", ui.RenderPass("✓ Migration complete!"))
|
||||
fmt.Println()
|
||||
fmt.Printf("Your beads now use SQLite storage.\n")
|
||||
fmt.Println()
|
||||
fmt.Println("Next steps:")
|
||||
fmt.Println(" • Verify data: bd list")
|
||||
fmt.Println(" • After verification, you can delete the Dolt directory:")
|
||||
fmt.Printf(" rm -rf %s\n", doltPath)
|
||||
}
|
||||
}
|
||||
|
||||
// importIssuesToSQLite imports issues to SQLite, returning (imported, skipped, error)
|
||||
func importIssuesToSQLite(ctx context.Context, store *sqlite.SQLiteStorage, issues []*types.Issue) (int, int, error) {
|
||||
imported := 0
|
||||
skipped := 0
|
||||
|
||||
err := store.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
||||
seenIDs := make(map[string]bool)
|
||||
|
||||
for _, issue := range issues {
|
||||
if seenIDs[issue.ID] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
seenIDs[issue.ID] = true
|
||||
|
||||
// Create issue (labels are handled separately)
|
||||
savedLabels := issue.Labels
|
||||
savedDeps := issue.Dependencies
|
||||
issue.Labels = nil // Don't process in CreateIssue
|
||||
issue.Dependencies = nil // Don't process in CreateIssue
|
||||
|
||||
if err := tx.CreateIssue(ctx, issue, "migration"); err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") ||
|
||||
strings.Contains(err.Error(), "already exists") {
|
||||
skipped++
|
||||
issue.Labels = savedLabels
|
||||
issue.Dependencies = savedDeps
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err)
|
||||
}
|
||||
|
||||
// Add labels
|
||||
for _, label := range savedLabels {
|
||||
if err := tx.AddLabel(ctx, issue.ID, label, "migration"); err != nil {
|
||||
// Non-fatal for labels
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
issue.Labels = savedLabels
|
||||
issue.Dependencies = savedDeps
|
||||
imported++
|
||||
}
|
||||
|
||||
// Import dependencies in a second pass
|
||||
for _, issue := range issues {
|
||||
for _, dep := range issue.Dependencies {
|
||||
if err := tx.AddDependency(ctx, dep, "migration"); err != nil {
|
||||
// Non-fatal for dependencies
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return imported, skipped, err
|
||||
}
|
||||
|
||||
return imported, skipped, nil
|
||||
}
|
||||
|
||||
// Helper functions for nullable values
|
||||
func nullableString(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullableStringPtr(s *string) interface{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func nullableIntPtr(i *int) interface{} {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
return *i
|
||||
}
|
||||
|
||||
func nullableInt(i int) interface{} {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func nullableFloat32Ptr(f *float32) interface{} {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
func formatWaiters(arr []string) string {
|
||||
if len(arr) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(arr, ",")
|
||||
}
|
||||
40
cmd/bd/migrate_dolt_nocgo.go
Normal file
40
cmd/bd/migrate_dolt_nocgo.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build !cgo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// handleToDoltMigration is a stub for non-cgo builds.
|
||||
// Dolt requires CGO, so this migration is not available.
|
||||
func handleToDoltMigration(dryRun bool, autoYes bool) {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "dolt_not_available",
|
||||
"message": "Dolt backend requires CGO. This binary was built without CGO support.",
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: Dolt backend requires CGO\n")
|
||||
fmt.Fprintf(os.Stderr, "This binary was built without CGO support.\n")
|
||||
fmt.Fprintf(os.Stderr, "To use Dolt, rebuild with: CGO_ENABLED=1 go build\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// handleToSQLiteMigration is a stub for non-cgo builds.
|
||||
// Dolt requires CGO, so this migration from Dolt is not available.
|
||||
func handleToSQLiteMigration(dryRun bool, autoYes bool) {
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"error": "dolt_not_available",
|
||||
"message": "Dolt backend requires CGO. This binary was built without CGO support.",
|
||||
})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: Dolt backend requires CGO\n")
|
||||
fmt.Fprintf(os.Stderr, "This binary was built without CGO support.\n")
|
||||
fmt.Fprintf(os.Stderr, "To use Dolt, rebuild with: CGO_ENABLED=1 go build\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user