diff --git a/internal/storage/sqlite/migrations/019_messaging_fields.go b/internal/storage/sqlite/migrations/019_messaging_fields.go index d5eddd65..6b44d237 100644 --- a/internal/storage/sqlite/migrations/019_messaging_fields.go +++ b/internal/storage/sqlite/migrations/019_messaging_fields.go @@ -20,10 +20,6 @@ func MigrateMessagingFields(db *sql.DB) error { }{ {"sender", "TEXT DEFAULT ''"}, {"ephemeral", "INTEGER DEFAULT 0"}, - {"replies_to", "TEXT DEFAULT ''"}, - {"relates_to", "TEXT DEFAULT ''"}, - {"duplicate_of", "TEXT DEFAULT ''"}, - {"superseded_by", "TEXT DEFAULT ''"}, } for _, col := range columns { @@ -59,11 +55,5 @@ func MigrateMessagingFields(db *sql.DB) error { return fmt.Errorf("failed to create sender index: %w", err) } - // Add index for replies_to (for efficient thread queries) - _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_replies_to ON issues(replies_to) WHERE replies_to != ''`) - if err != nil { - return fmt.Errorf("failed to create replies_to index: %w", err) - } - return nil } diff --git a/internal/storage/sqlite/migrations/021_migrate_edge_fields.go b/internal/storage/sqlite/migrations/021_migrate_edge_fields.go index 35f1b295..69013a54 100644 --- a/internal/storage/sqlite/migrations/021_migrate_edge_fields.go +++ b/internal/storage/sqlite/migrations/021_migrate_edge_fields.go @@ -21,137 +21,176 @@ import ( func MigrateEdgeFields(db *sql.DB) error { now := time.Now() + hasColumn := func(name string) (bool, error) { + var exists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = ? + `, name).Scan(&exists) + return exists, err + } + + hasRepliesTo, err := hasColumn("replies_to") + if err != nil { + return fmt.Errorf("failed to check replies_to column: %w", err) + } + hasRelatesTo, err := hasColumn("relates_to") + if err != nil { + return fmt.Errorf("failed to check relates_to column: %w", err) + } + hasDuplicateOf, err := hasColumn("duplicate_of") + if err != nil { + return fmt.Errorf("failed to check duplicate_of column: %w", err) + } + hasSupersededBy, err := hasColumn("superseded_by") + if err != nil { + return fmt.Errorf("failed to check superseded_by column: %w", err) + } + + if !hasRepliesTo && !hasRelatesTo && !hasDuplicateOf && !hasSupersededBy { + return nil + } + // Migrate replies_to fields to replies-to edges // For thread_id, use the parent's ID as the thread root for first-level replies // (more sophisticated thread detection would require recursive queries) - rows, err := db.Query(` - SELECT id, replies_to - FROM issues - WHERE replies_to != '' AND replies_to IS NOT NULL - `) - if err != nil { - return fmt.Errorf("failed to query replies_to fields: %w", err) - } - defer rows.Close() - - for rows.Next() { - var issueID, repliesTo string - if err := rows.Scan(&issueID, &repliesTo); err != nil { - return fmt.Errorf("failed to scan replies_to row: %w", err) - } - - // Use repliesTo as thread_id (the root of the thread) - // This is a simplification - existing threads will have the parent as thread root - _, err := db.Exec(` - INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) - VALUES (?, ?, 'replies-to', ?, 'migration', '{}', ?) - `, issueID, repliesTo, now, repliesTo) + if hasRepliesTo { + rows, err := db.Query(` + SELECT id, replies_to + FROM issues + WHERE replies_to != '' AND replies_to IS NOT NULL + `) if err != nil { - return fmt.Errorf("failed to create replies-to edge for %s: %w", issueID, err) + return fmt.Errorf("failed to query replies_to fields: %w", err) + } + defer rows.Close() + + for rows.Next() { + var issueID, repliesTo string + if err := rows.Scan(&issueID, &repliesTo); err != nil { + return fmt.Errorf("failed to scan replies_to row: %w", err) + } + + // Use repliesTo as thread_id (the root of the thread) + // This is a simplification - existing threads will have the parent as thread root + _, err := db.Exec(` + INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) + VALUES (?, ?, 'replies-to', ?, 'migration', '{}', ?) + `, issueID, repliesTo, now, repliesTo) + if err != nil { + return fmt.Errorf("failed to create replies-to edge for %s: %w", issueID, err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating replies_to rows: %w", err) } - } - if err := rows.Err(); err != nil { - return fmt.Errorf("error iterating replies_to rows: %w", err) } // Migrate relates_to fields to relates-to edges // relates_to is stored as JSON array string - rows, err = db.Query(` - SELECT id, relates_to - FROM issues - WHERE relates_to != '' AND relates_to != '[]' AND relates_to IS NOT NULL - `) - if err != nil { - return fmt.Errorf("failed to query relates_to fields: %w", err) - } - defer rows.Close() - - for rows.Next() { - var issueID, relatesTo string - if err := rows.Scan(&issueID, &relatesTo); err != nil { - return fmt.Errorf("failed to scan relates_to row: %w", err) + if hasRelatesTo { + rows, err := db.Query(` + SELECT id, relates_to + FROM issues + WHERE relates_to != '' AND relates_to != '[]' AND relates_to IS NOT NULL + `) + if err != nil { + return fmt.Errorf("failed to query relates_to fields: %w", err) } + defer rows.Close() - // Parse JSON array - var relatedIDs []string - if err := json.Unmarshal([]byte(relatesTo), &relatedIDs); err != nil { - // Skip malformed JSON - continue - } + for rows.Next() { + var issueID, relatesTo string + if err := rows.Scan(&issueID, &relatesTo); err != nil { + return fmt.Errorf("failed to scan relates_to row: %w", err) + } - for _, relatedID := range relatedIDs { - if relatedID == "" { + // Parse JSON array + var relatedIDs []string + if err := json.Unmarshal([]byte(relatesTo), &relatedIDs); err != nil { + // Skip malformed JSON continue } - _, err := db.Exec(` - INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) - VALUES (?, ?, 'relates-to', ?, 'migration', '{}', '') - `, issueID, relatedID, now) - if err != nil { - return fmt.Errorf("failed to create relates-to edge for %s -> %s: %w", issueID, relatedID, err) + + for _, relatedID := range relatedIDs { + if relatedID == "" { + continue + } + _, err := db.Exec(` + INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) + VALUES (?, ?, 'relates-to', ?, 'migration', '{}', '') + `, issueID, relatedID, now) + if err != nil { + return fmt.Errorf("failed to create relates-to edge for %s -> %s: %w", issueID, relatedID, err) + } } } - } - if err := rows.Err(); err != nil { - return fmt.Errorf("error iterating relates_to rows: %w", err) + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating relates_to rows: %w", err) + } } // Migrate duplicate_of fields to duplicates edges - rows, err = db.Query(` - SELECT id, duplicate_of - FROM issues - WHERE duplicate_of != '' AND duplicate_of IS NOT NULL - `) - if err != nil { - return fmt.Errorf("failed to query duplicate_of fields: %w", err) - } - defer rows.Close() - - for rows.Next() { - var issueID, duplicateOf string - if err := rows.Scan(&issueID, &duplicateOf); err != nil { - return fmt.Errorf("failed to scan duplicate_of row: %w", err) - } - - _, err := db.Exec(` - INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) - VALUES (?, ?, 'duplicates', ?, 'migration', '{}', '') - `, issueID, duplicateOf, now) + if hasDuplicateOf { + rows, err := db.Query(` + SELECT id, duplicate_of + FROM issues + WHERE duplicate_of != '' AND duplicate_of IS NOT NULL + `) if err != nil { - return fmt.Errorf("failed to create duplicates edge for %s: %w", issueID, err) + return fmt.Errorf("failed to query duplicate_of fields: %w", err) + } + defer rows.Close() + + for rows.Next() { + var issueID, duplicateOf string + if err := rows.Scan(&issueID, &duplicateOf); err != nil { + return fmt.Errorf("failed to scan duplicate_of row: %w", err) + } + + _, err := db.Exec(` + INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) + VALUES (?, ?, 'duplicates', ?, 'migration', '{}', '') + `, issueID, duplicateOf, now) + if err != nil { + return fmt.Errorf("failed to create duplicates edge for %s: %w", issueID, err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating duplicate_of rows: %w", err) } - } - if err := rows.Err(); err != nil { - return fmt.Errorf("error iterating duplicate_of rows: %w", err) } // Migrate superseded_by fields to supersedes edges - rows, err = db.Query(` - SELECT id, superseded_by - FROM issues - WHERE superseded_by != '' AND superseded_by IS NOT NULL - `) - if err != nil { - return fmt.Errorf("failed to query superseded_by fields: %w", err) - } - defer rows.Close() - - for rows.Next() { - var issueID, supersededBy string - if err := rows.Scan(&issueID, &supersededBy); err != nil { - return fmt.Errorf("failed to scan superseded_by row: %w", err) - } - - _, err := db.Exec(` - INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) - VALUES (?, ?, 'supersedes', ?, 'migration', '{}', '') - `, issueID, supersededBy, now) + if hasSupersededBy { + rows, err := db.Query(` + SELECT id, superseded_by + FROM issues + WHERE superseded_by != '' AND superseded_by IS NOT NULL + `) if err != nil { - return fmt.Errorf("failed to create supersedes edge for %s: %w", issueID, err) + return fmt.Errorf("failed to query superseded_by fields: %w", err) + } + defer rows.Close() + + for rows.Next() { + var issueID, supersededBy string + if err := rows.Scan(&issueID, &supersededBy); err != nil { + return fmt.Errorf("failed to scan superseded_by row: %w", err) + } + + _, err := db.Exec(` + INSERT OR IGNORE INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id) + VALUES (?, ?, 'supersedes', ?, 'migration', '{}', '') + `, issueID, supersededBy, now) + if err != nil { + return fmt.Errorf("failed to create supersedes edge for %s: %w", issueID, err) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("error iterating superseded_by rows: %w", err) } - } - if err := rows.Err(); err != nil { - return fmt.Errorf("error iterating superseded_by rows: %w", err) } return nil diff --git a/internal/storage/sqlite/migrations/022_drop_edge_columns.go b/internal/storage/sqlite/migrations/022_drop_edge_columns.go index 944bddb5..b2cee13e 100644 --- a/internal/storage/sqlite/migrations/022_drop_edge_columns.go +++ b/internal/storage/sqlite/migrations/022_drop_edge_columns.go @@ -57,6 +57,57 @@ func MigrateDropEdgeColumns(db *sql.DB) error { return nil } + // Preserve newer columns if they already exist (migration may run on partially-migrated DBs). + hasPinned, err := checkCol("pinned") + if err != nil { + return fmt.Errorf("failed to check pinned column: %w", err) + } + hasIsTemplate, err := checkCol("is_template") + if err != nil { + return fmt.Errorf("failed to check is_template column: %w", err) + } + hasAwaitType, err := checkCol("await_type") + if err != nil { + return fmt.Errorf("failed to check await_type column: %w", err) + } + hasAwaitID, err := checkCol("await_id") + if err != nil { + return fmt.Errorf("failed to check await_id column: %w", err) + } + hasTimeoutNs, err := checkCol("timeout_ns") + if err != nil { + return fmt.Errorf("failed to check timeout_ns column: %w", err) + } + hasWaiters, err := checkCol("waiters") + if err != nil { + return fmt.Errorf("failed to check waiters column: %w", err) + } + + pinnedExpr := "0" + if hasPinned { + pinnedExpr = "pinned" + } + isTemplateExpr := "0" + if hasIsTemplate { + isTemplateExpr = "is_template" + } + awaitTypeExpr := "''" + if hasAwaitType { + awaitTypeExpr = "await_type" + } + awaitIDExpr := "''" + if hasAwaitID { + awaitIDExpr = "await_id" + } + timeoutNsExpr := "0" + if hasTimeoutNs { + timeoutNsExpr = "timeout_ns" + } + waitersExpr := "''" + if hasWaiters { + waitersExpr = "waiters" + } + // SQLite 3.35.0+ supports DROP COLUMN, but we use table recreation for compatibility // This is idempotent - we recreate the table without the deprecated columns @@ -117,6 +168,12 @@ func MigrateDropEdgeColumns(db *sql.DB) error { original_type TEXT DEFAULT '', sender TEXT DEFAULT '', ephemeral INTEGER DEFAULT 0, + pinned INTEGER DEFAULT 0, + is_template INTEGER DEFAULT 0, + await_type TEXT, + await_id TEXT, + timeout_ns INTEGER, + waiters TEXT, close_reason TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ) @@ -132,7 +189,8 @@ func MigrateDropEdgeColumns(db *sql.DB) error { notes, status, priority, issue_type, assignee, estimated_minutes, created_at, updated_at, closed_at, external_ref, source_repo, compaction_level, compacted_at, compacted_at_commit, original_size, deleted_at, - deleted_by, delete_reason, original_type, sender, ephemeral, close_reason + deleted_by, delete_reason, original_type, sender, ephemeral, pinned, is_template, + await_type, await_id, timeout_ns, waiters, close_reason ) SELECT id, content_hash, title, description, design, acceptance_criteria, @@ -140,9 +198,11 @@ func MigrateDropEdgeColumns(db *sql.DB) error { created_at, updated_at, closed_at, external_ref, COALESCE(source_repo, ''), compaction_level, compacted_at, compacted_at_commit, original_size, deleted_at, deleted_by, delete_reason, original_type, sender, ephemeral, + %s, %s, + %s, %s, %s, %s, COALESCE(close_reason, '') FROM issues - `) + `, pinnedExpr, isTemplateExpr, awaitTypeExpr, awaitIDExpr, timeoutNsExpr, waitersExpr) if err != nil { return fmt.Errorf("failed to copy issues data: %w", err) } diff --git a/internal/storage/sqlite/migrations/023_pinned_column.go b/internal/storage/sqlite/migrations/023_pinned_column.go index 9854f8e0..73c238dc 100644 --- a/internal/storage/sqlite/migrations/023_pinned_column.go +++ b/internal/storage/sqlite/migrations/023_pinned_column.go @@ -20,6 +20,11 @@ func MigratePinnedColumn(db *sql.DB) error { } if columnExists { + // Column exists (e.g. created by new schema); ensure index exists. + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_pinned ON issues(pinned) WHERE pinned = 1`) + if err != nil { + return fmt.Errorf("failed to create pinned index: %w", err) + } return nil } diff --git a/internal/storage/sqlite/migrations/024_is_template_column.go b/internal/storage/sqlite/migrations/024_is_template_column.go index 07f9462c..fee0316d 100644 --- a/internal/storage/sqlite/migrations/024_is_template_column.go +++ b/internal/storage/sqlite/migrations/024_is_template_column.go @@ -21,6 +21,11 @@ func MigrateIsTemplateColumn(db *sql.DB) error { } if columnExists { + // Column exists (e.g. created by new schema); ensure index exists. + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_issues_is_template ON issues(is_template) WHERE is_template = 1`) + if err != nil { + return fmt.Errorf("failed to create is_template index: %w", err) + } return nil } diff --git a/internal/storage/sqlite/migrations_template_pinned_regression_test.go b/internal/storage/sqlite/migrations_template_pinned_regression_test.go new file mode 100644 index 00000000..818596bb --- /dev/null +++ b/internal/storage/sqlite/migrations_template_pinned_regression_test.go @@ -0,0 +1,59 @@ +package sqlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestRunMigrations_DoesNotResetPinnedOrTemplate(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + dbPath := filepath.Join(dir, "beads.db") + + s, err := New(ctx, dbPath) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + if err := s.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("SetConfig(issue_prefix): %v", err) + } + + issue := &types.Issue{ + Title: "Pinned template", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + Pinned: true, + IsTemplate: true, + } + if err := s.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue: %v", err) + } + + _ = s.Close() + + s2, err := New(ctx, dbPath) + if err != nil { + t.Fatalf("New(reopen): %v", err) + } + defer func() { _ = s2.Close() }() + + got, err := s2.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("GetIssue: %v", err) + } + if got == nil { + t.Fatalf("expected issue to exist") + } + if !got.Pinned { + t.Fatalf("expected issue to remain pinned") + } + if !got.IsTemplate { + t.Fatalf("expected issue to remain template") + } +}