Files
beads/internal/storage/sqlite/migrations/022_drop_edge_columns.go
Jordan Hubbard aa2ea48bf2 feat: add FreeBSD release builds (#832)
* feat: add FreeBSD release builds

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* chore: allow manual release dispatch

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: stabilize release workflow on fork

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: clean zig download artifact

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: use valid zig target for freebsd arm

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: disable freebsd arm release build

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: switch freebsd build to pure go

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: skip release publishing on forks

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: satisfy golangci-lint for release PR

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-01-01 10:51:51 -08:00

302 lines
9.6 KiB
Go

package migrations
import (
"database/sql"
"fmt"
)
// MigrateDropEdgeColumns removes the deprecated edge fields from the issues table.
// This is Phase 4 of the Edge Schema Consolidation (Decision 004).
//
// Removes columns:
// - replies_to (now: replies-to dependency)
// - relates_to (now: relates-to dependencies)
// - duplicate_of (now: duplicates dependency)
// - superseded_by (now: supersedes dependency)
//
// Prerequisites:
// - Migration 021 (migrate_edge_fields) must have already run to convert data
// - All code must be updated to use the dependencies API
//
// SQLite doesn't support DROP COLUMN directly in older versions, so we
// recreate the table without the deprecated columns.
func MigrateDropEdgeColumns(db *sql.DB) error {
// Check if any of the columns still exist
var hasRepliesTo, hasRelatesTo, hasDuplicateOf, hasSupersededBy bool
checkCol := 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
}
var err error
hasRepliesTo, err = checkCol("replies_to")
if err != nil {
return fmt.Errorf("failed to check replies_to column: %w", err)
}
hasRelatesTo, err = checkCol("relates_to")
if err != nil {
return fmt.Errorf("failed to check relates_to column: %w", err)
}
hasDuplicateOf, err = checkCol("duplicate_of")
if err != nil {
return fmt.Errorf("failed to check duplicate_of column: %w", err)
}
hasSupersededBy, err = checkCol("superseded_by")
if err != nil {
return fmt.Errorf("failed to check superseded_by column: %w", err)
}
// If none of the columns exist, migration already ran
if !hasRepliesTo && !hasRelatesTo && !hasDuplicateOf && !hasSupersededBy {
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
// NOTE: Foreign keys are disabled in RunMigrations() before the EXCLUSIVE transaction starts.
// This prevents ON DELETE CASCADE from deleting dependencies when we drop/recreate the issues table.
// Drop views that depend on the issues table BEFORE starting savepoint
// This is necessary because SQLite validates views during table operations
_, err = db.Exec(`DROP VIEW IF EXISTS ready_issues`)
if err != nil {
return fmt.Errorf("failed to drop ready_issues view: %w", err)
}
_, err = db.Exec(`DROP VIEW IF EXISTS blocked_issues`)
if err != nil {
return fmt.Errorf("failed to drop blocked_issues view: %w", err)
}
// Use SAVEPOINT for atomicity (we're already inside an EXCLUSIVE transaction from RunMigrations)
// SQLite doesn't support nested transactions but SAVEPOINTs work inside transactions
_, err = db.Exec(`SAVEPOINT drop_edge_columns`)
if err != nil {
return fmt.Errorf("failed to create savepoint: %w", err)
}
savepointReleased := false
defer func() {
if !savepointReleased {
_, _ = db.Exec(`ROLLBACK TO SAVEPOINT drop_edge_columns`)
}
}()
// Create new table without the deprecated columns
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS issues_new (
id TEXT PRIMARY KEY,
content_hash TEXT,
title TEXT NOT NULL CHECK(length(title) <= 500),
description TEXT NOT NULL DEFAULT '',
design TEXT NOT NULL DEFAULT '',
acceptance_criteria TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
priority INTEGER NOT NULL DEFAULT 2 CHECK(priority >= 0 AND priority <= 4),
issue_type TEXT NOT NULL DEFAULT 'task',
assignee TEXT,
estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
external_ref TEXT,
source_repo TEXT DEFAULT '',
compaction_level INTEGER DEFAULT 0,
compacted_at DATETIME,
compacted_at_commit TEXT,
original_size INTEGER,
deleted_at DATETIME,
deleted_by TEXT DEFAULT '',
delete_reason TEXT DEFAULT '',
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))
)
`)
if err != nil {
return fmt.Errorf("failed to create new issues table: %w", err)
}
// Copy data from old table to new table (excluding deprecated columns)
// NOTE: We use fmt.Sprintf here (not db.Exec parameters) because we're interpolating
// column names/expressions, not values. db.Exec parameters only work for VALUES.
// #nosec G201 - expressions are column names, not user input
copySQL := fmt.Sprintf(`
INSERT INTO issues_new (
id, content_hash, title, description, design, acceptance_criteria,
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, pinned, is_template,
await_type, await_id, timeout_ns, waiters, close_reason
)
SELECT
id, content_hash, title, description, design, acceptance_criteria,
notes, status, priority, issue_type, assignee, estimated_minutes,
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)
_, err = db.Exec(copySQL)
if err != nil {
return fmt.Errorf("failed to copy issues data: %w", err)
}
// Drop old table
_, err = db.Exec(`DROP TABLE issues`)
if err != nil {
return fmt.Errorf("failed to drop old issues table: %w", err)
}
// Rename new table to issues
_, err = db.Exec(`ALTER TABLE issues_new RENAME TO issues`)
if err != nil {
return fmt.Errorf("failed to rename new issues table: %w", err)
}
// Recreate indexes
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status)`,
`CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority)`,
`CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee)`,
`CREATE INDEX IF NOT EXISTS idx_issues_created_at ON issues(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_issues_external_ref ON issues(external_ref) WHERE external_ref IS NOT NULL`,
}
for _, idx := range indexes {
_, err = db.Exec(idx)
if err != nil {
return fmt.Errorf("failed to create index: %w", err)
}
}
// Release savepoint (commits the changes within the outer transaction)
_, err = db.Exec(`RELEASE SAVEPOINT drop_edge_columns`)
if err != nil {
return fmt.Errorf("failed to release savepoint: %w", err)
}
savepointReleased = true
// Recreate views that we dropped earlier (after commit, outside transaction)
// ready_issues view
_, err = db.Exec(`
CREATE VIEW IF NOT EXISTS ready_issues AS
WITH RECURSIVE
blocked_directly AS (
SELECT DISTINCT d.issue_id
FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
),
blocked_transitively AS (
SELECT issue_id, 0 as depth
FROM blocked_directly
UNION ALL
SELECT d.issue_id, bt.depth + 1
FROM blocked_transitively bt
JOIN dependencies d ON d.depends_on_id = bt.issue_id
WHERE d.type = 'parent-child'
AND bt.depth < 50
)
SELECT i.*
FROM issues i
WHERE i.status = 'open'
AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
)
`)
if err != nil {
return fmt.Errorf("failed to recreate ready_issues view: %w", err)
}
// blocked_issues view
_, err = db.Exec(`
CREATE VIEW IF NOT EXISTS blocked_issues AS
SELECT
i.*,
COUNT(d.depends_on_id) as blocked_by_count
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE i.status IN ('open', 'in_progress', 'blocked')
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
GROUP BY i.id
`)
if err != nil {
return fmt.Errorf("failed to recreate blocked_issues view: %w", err)
}
return nil
}