From 5d7187f29b698b25a57408efc880aa27fec12da7 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 01:22:34 -0800 Subject: [PATCH] Phase 3: Migrate existing edge fields to dependencies (Decision 004) Add migration to convert existing issue fields to dependency edges: - replies_to -> replies-to dependency with thread_id - relates_to -> relates-to dependencies (JSON array) - duplicate_of -> duplicates dependency - superseded_by -> supersedes dependency The migration is idempotent (INSERT OR IGNORE) so it does not duplicate edges that were already created by Phase 2 dual-write. Generated with Claude Code Co-Authored-By: Claude Opus 4.5 --- internal/storage/sqlite/migrations.go | 2 + .../migrations/021_migrate_edge_fields.go | 158 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 internal/storage/sqlite/migrations/021_migrate_edge_fields.go diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index c6631df9..cbb81563 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -37,6 +37,7 @@ var migrationsList = []Migration{ {"tombstone_columns", migrations.MigrateTombstoneColumns}, {"messaging_fields", migrations.MigrateMessagingFields}, {"edge_consolidation", migrations.MigrateEdgeConsolidation}, + {"migrate_edge_fields", migrations.MigrateEdgeFields}, } // MigrationInfo contains metadata about a migration for inspection @@ -81,6 +82,7 @@ func getMigrationDescription(name string) string { "tombstone_columns": "Adds tombstone columns (deleted_at, deleted_by, delete_reason, original_type) for inline soft-delete (bd-vw8)", "messaging_fields": "Adds messaging fields (sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by) for inter-agent communication (bd-kwro)", "edge_consolidation": "Adds metadata and thread_id columns to dependencies table for edge schema consolidation (Decision 004)", + "migrate_edge_fields": "Migrates existing issue fields (replies_to, relates_to, duplicate_of, superseded_by) to dependency edges (Decision 004 Phase 3)", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/021_migrate_edge_fields.go b/internal/storage/sqlite/migrations/021_migrate_edge_fields.go new file mode 100644 index 00000000..35f1b295 --- /dev/null +++ b/internal/storage/sqlite/migrations/021_migrate_edge_fields.go @@ -0,0 +1,158 @@ +package migrations + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// MigrateEdgeFields migrates existing issue fields to dependency edges. +// This is Phase 3 of the Edge Schema Consolidation (Decision 004). +// +// Migrates: +// - replies_to -> replies-to dependency with thread_id +// - relates_to -> relates-to dependencies +// - duplicate_of -> duplicates dependency +// - superseded_by -> supersedes dependency +// +// This migration is idempotent: it uses INSERT OR IGNORE to skip +// edges that already exist (from Phase 2 dual-write). +func MigrateEdgeFields(db *sql.DB) error { + now := time.Now() + + // 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 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) + } + + // 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) + } + + // Parse JSON array + var relatedIDs []string + if err := json.Unmarshal([]byte(relatesTo), &relatedIDs); err != nil { + // Skip malformed JSON + continue + } + + 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) + } + + // 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 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) + } + + // 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 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) + } + + return nil +}