Split internal/storage/sqlite/queries.go (1586 lines) into logical modules: - queries.go (650 lines) - Core CRUD: CreateIssue, GetIssue, UpdateIssue, CloseIssue - queries_search.go (429 lines) - Search/filter: SearchIssues, GetIssueByExternalRef, GetCloseReason, GetCloseReasonsForIssues - queries_delete.go (464 lines) - Delete operations: CreateTombstone, DeleteIssue, DeleteIssues and cascade logic - queries_rename.go (149 lines) - ID/prefix operations: UpdateIssueID, RenameDependencyPrefix, RenameCounterPrefix, ResetCounter - queries_helpers.go (50 lines) - Utilities: parseNullableTimeString, parseJSONStringArray, formatJSONStringArray All tests pass. No functional changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
465 lines
14 KiB
Go
465 lines
14 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// CreateTombstone converts an existing issue to a tombstone record.
|
|
// This is a soft-delete that preserves the issue in the database with status="tombstone".
|
|
// The issue will still appear in exports but be excluded from normal queries.
|
|
// Dependencies must be removed separately before calling this method.
|
|
func (s *SQLiteStorage) CreateTombstone(ctx context.Context, id string, actor string, reason string) error {
|
|
// Get the issue to preserve its original type
|
|
issue, err := s.GetIssue(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue: %w", err)
|
|
}
|
|
if issue == nil {
|
|
return fmt.Errorf("issue not found: %s", id)
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
now := time.Now()
|
|
originalType := string(issue.IssueType)
|
|
|
|
// Convert issue to tombstone
|
|
// Note: closed_at must be set to NULL because of CHECK constraint:
|
|
// (status = 'closed') = (closed_at IS NOT NULL)
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE issues
|
|
SET status = ?,
|
|
closed_at = NULL,
|
|
deleted_at = ?,
|
|
deleted_by = ?,
|
|
delete_reason = ?,
|
|
original_type = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`, types.StatusTombstone, now, actor, reason, originalType, now, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create tombstone: %w", err)
|
|
}
|
|
|
|
// Record tombstone creation event
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO events (issue_id, event_type, actor, comment)
|
|
VALUES (?, ?, ?, ?)
|
|
`, id, "deleted", actor, reason)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to record tombstone event: %w", err)
|
|
}
|
|
|
|
// Mark issue as dirty for incremental export
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO dirty_issues (issue_id, marked_at)
|
|
VALUES (?, ?)
|
|
ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at
|
|
`, id, now)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark issue dirty: %w", err)
|
|
}
|
|
|
|
// Invalidate blocked issues cache since status changed (bd-5qim)
|
|
// Tombstone issues don't block others, so this affects blocking calculations
|
|
if err := s.invalidateBlockedCache(ctx, tx); err != nil {
|
|
return fmt.Errorf("failed to invalidate blocked cache: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return wrapDBError("commit tombstone transaction", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteIssue permanently removes an issue from the database
|
|
func (s *SQLiteStorage) DeleteIssue(ctx context.Context, id string) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
// Delete dependencies (both directions)
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete dependencies: %w", err)
|
|
}
|
|
|
|
// Delete events
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM events WHERE issue_id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete events: %w", err)
|
|
}
|
|
|
|
// Delete comments (no FK cascade on this table) (bd-687g)
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM comments WHERE issue_id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete comments: %w", err)
|
|
}
|
|
|
|
// Delete from dirty_issues
|
|
_, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete dirty marker: %w", err)
|
|
}
|
|
|
|
// Delete the issue itself
|
|
result, err := tx.ExecContext(ctx, `DELETE FROM issues WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete issue: %w", err)
|
|
}
|
|
|
|
rowsAffected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check rows affected: %w", err)
|
|
}
|
|
if rowsAffected == 0 {
|
|
return fmt.Errorf("issue not found: %s", id)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return wrapDBError("commit delete transaction", err)
|
|
}
|
|
|
|
// REMOVED (bd-c7af): Counter sync after deletion - no longer needed with hash IDs
|
|
return nil
|
|
}
|
|
|
|
// DeleteIssuesResult contains statistics about a batch deletion operation
|
|
type DeleteIssuesResult struct {
|
|
DeletedCount int
|
|
DependenciesCount int
|
|
LabelsCount int
|
|
EventsCount int
|
|
OrphanedIssues []string
|
|
}
|
|
|
|
// DeleteIssues deletes multiple issues in a single transaction
|
|
// If cascade is true, recursively deletes dependents
|
|
// If cascade is false but force is true, deletes issues and orphans their dependents
|
|
// If cascade and force are both false, returns an error if any issue has dependents
|
|
// If dryRun is true, only computes statistics without deleting
|
|
func (s *SQLiteStorage) DeleteIssues(ctx context.Context, ids []string, cascade bool, force bool, dryRun bool) (*DeleteIssuesResult, error) {
|
|
if len(ids) == 0 {
|
|
return &DeleteIssuesResult{}, nil
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
idSet := buildIDSet(ids)
|
|
result := &DeleteIssuesResult{}
|
|
|
|
expandedIDs, err := s.resolveDeleteSet(ctx, tx, ids, idSet, cascade, force, result)
|
|
if err != nil {
|
|
return nil, wrapDBError("resolve delete set", err)
|
|
}
|
|
|
|
inClause, args := buildSQLInClause(expandedIDs)
|
|
if err := s.populateDeleteStats(ctx, tx, inClause, args, result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if dryRun {
|
|
return result, nil
|
|
}
|
|
|
|
if err := s.executeDelete(ctx, tx, inClause, args, result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
// REMOVED (bd-c7af): Counter sync after deletion - no longer needed with hash IDs
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func buildIDSet(ids []string) map[string]bool {
|
|
idSet := make(map[string]bool, len(ids))
|
|
for _, id := range ids {
|
|
idSet[id] = true
|
|
}
|
|
return idSet
|
|
}
|
|
|
|
func (s *SQLiteStorage) resolveDeleteSet(ctx context.Context, tx *sql.Tx, ids []string, idSet map[string]bool, cascade bool, force bool, result *DeleteIssuesResult) ([]string, error) {
|
|
if cascade {
|
|
return s.expandWithDependents(ctx, tx, ids, idSet)
|
|
}
|
|
if !force {
|
|
return ids, s.validateNoDependents(ctx, tx, ids, idSet, result)
|
|
}
|
|
return ids, s.trackOrphanedIssues(ctx, tx, ids, idSet, result)
|
|
}
|
|
|
|
func (s *SQLiteStorage) expandWithDependents(ctx context.Context, tx *sql.Tx, ids []string, _ map[string]bool) ([]string, error) {
|
|
allToDelete, err := s.findAllDependentsRecursive(ctx, tx, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find dependents: %w", err)
|
|
}
|
|
expandedIDs := make([]string, 0, len(allToDelete))
|
|
for id := range allToDelete {
|
|
expandedIDs = append(expandedIDs, id)
|
|
}
|
|
return expandedIDs, nil
|
|
}
|
|
|
|
func (s *SQLiteStorage) validateNoDependents(ctx context.Context, tx *sql.Tx, ids []string, idSet map[string]bool, result *DeleteIssuesResult) error {
|
|
for _, id := range ids {
|
|
if err := s.checkSingleIssueValidation(ctx, tx, id, idSet, result); err != nil {
|
|
return wrapDBError("check dependents", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStorage) checkSingleIssueValidation(ctx context.Context, tx *sql.Tx, id string, idSet map[string]bool, result *DeleteIssuesResult) error {
|
|
var depCount int
|
|
err := tx.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM dependencies WHERE depends_on_id = ?`, id).Scan(&depCount)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check dependents for %s: %w", id, err)
|
|
}
|
|
if depCount == 0 {
|
|
return nil
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx,
|
|
`SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get dependents for %s: %w", id, err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
hasExternal := false
|
|
for rows.Next() {
|
|
var depID string
|
|
if err := rows.Scan(&depID); err != nil {
|
|
return fmt.Errorf("failed to scan dependent: %w", err)
|
|
}
|
|
if !idSet[depID] {
|
|
hasExternal = true
|
|
result.OrphanedIssues = append(result.OrphanedIssues, depID)
|
|
}
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("failed to iterate dependents for %s: %w", id, err)
|
|
}
|
|
|
|
if hasExternal {
|
|
return fmt.Errorf("issue %s has dependents not in deletion set; use --cascade to delete them or --force to orphan them", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStorage) trackOrphanedIssues(ctx context.Context, tx *sql.Tx, ids []string, idSet map[string]bool, result *DeleteIssuesResult) error {
|
|
orphanSet := make(map[string]bool)
|
|
for _, id := range ids {
|
|
if err := s.collectOrphansForID(ctx, tx, id, idSet, orphanSet); err != nil {
|
|
return wrapDBError("collect orphans", err)
|
|
}
|
|
}
|
|
for orphanID := range orphanSet {
|
|
result.OrphanedIssues = append(result.OrphanedIssues, orphanID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStorage) collectOrphansForID(ctx context.Context, tx *sql.Tx, id string, idSet map[string]bool, orphanSet map[string]bool) error {
|
|
rows, err := tx.QueryContext(ctx,
|
|
`SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get dependents for %s: %w", id, err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
for rows.Next() {
|
|
var depID string
|
|
if err := rows.Scan(&depID); err != nil {
|
|
return fmt.Errorf("failed to scan dependent: %w", err)
|
|
}
|
|
if !idSet[depID] {
|
|
orphanSet[depID] = true
|
|
}
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func buildSQLInClause(ids []string) (string, []interface{}) {
|
|
placeholders := make([]string, len(ids))
|
|
args := make([]interface{}, len(ids))
|
|
for i, id := range ids {
|
|
placeholders[i] = "?"
|
|
args[i] = id
|
|
}
|
|
return strings.Join(placeholders, ","), args
|
|
}
|
|
|
|
func (s *SQLiteStorage) populateDeleteStats(ctx context.Context, tx *sql.Tx, inClause string, args []interface{}, result *DeleteIssuesResult) error {
|
|
counts := []struct {
|
|
query string
|
|
dest *int
|
|
}{
|
|
{fmt.Sprintf(`SELECT COUNT(*) FROM dependencies WHERE issue_id IN (%s) OR depends_on_id IN (%s)`, inClause, inClause), &result.DependenciesCount},
|
|
{fmt.Sprintf(`SELECT COUNT(*) FROM labels WHERE issue_id IN (%s)`, inClause), &result.LabelsCount},
|
|
{fmt.Sprintf(`SELECT COUNT(*) FROM events WHERE issue_id IN (%s)`, inClause), &result.EventsCount},
|
|
}
|
|
|
|
for _, c := range counts {
|
|
queryArgs := args
|
|
if c.dest == &result.DependenciesCount {
|
|
queryArgs = append(args, args...)
|
|
}
|
|
if err := tx.QueryRowContext(ctx, c.query, queryArgs...).Scan(c.dest); err != nil {
|
|
return fmt.Errorf("failed to count: %w", err)
|
|
}
|
|
}
|
|
|
|
result.DeletedCount = len(args)
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStorage) executeDelete(ctx context.Context, tx *sql.Tx, inClause string, args []interface{}, result *DeleteIssuesResult) error {
|
|
// Note: This method now creates tombstones instead of hard-deleting (bd-3b4)
|
|
// Only dependencies are deleted - issues are converted to tombstones
|
|
|
|
// 1. Delete dependencies - tombstones don't block other issues
|
|
_, err := tx.ExecContext(ctx,
|
|
fmt.Sprintf(`DELETE FROM dependencies WHERE issue_id IN (%s) OR depends_on_id IN (%s)`, inClause, inClause),
|
|
append(args, args...)...)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete dependencies: %w", err)
|
|
}
|
|
|
|
// 2. Get issue types before converting to tombstones (need for original_type)
|
|
issueTypes := make(map[string]string)
|
|
rows, err := tx.QueryContext(ctx,
|
|
fmt.Sprintf(`SELECT id, issue_type FROM issues WHERE id IN (%s)`, inClause),
|
|
args...)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue types: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var id, issueType string
|
|
if err := rows.Scan(&id, &issueType); err != nil {
|
|
_ = rows.Close() // #nosec G104 - error handling not critical in error path
|
|
return fmt.Errorf("failed to scan issue type: %w", err)
|
|
}
|
|
issueTypes[id] = issueType
|
|
}
|
|
_ = rows.Close()
|
|
|
|
// 3. Convert issues to tombstones (only for issues that exist)
|
|
// Note: closed_at must be set to NULL because of CHECK constraint:
|
|
// (status = 'closed') = (closed_at IS NOT NULL)
|
|
now := time.Now()
|
|
deletedCount := 0
|
|
for id, originalType := range issueTypes {
|
|
execResult, err := tx.ExecContext(ctx, `
|
|
UPDATE issues
|
|
SET status = ?,
|
|
closed_at = NULL,
|
|
deleted_at = ?,
|
|
deleted_by = ?,
|
|
delete_reason = ?,
|
|
original_type = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`, types.StatusTombstone, now, "batch delete", "batch delete", originalType, now, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create tombstone for %s: %w", id, err)
|
|
}
|
|
|
|
rowsAffected, _ := execResult.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
continue // Issue doesn't exist, skip
|
|
}
|
|
deletedCount++
|
|
|
|
// Record tombstone creation event
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO events (issue_id, event_type, actor, comment)
|
|
VALUES (?, ?, ?, ?)
|
|
`, id, "deleted", "batch delete", "batch delete")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to record tombstone event for %s: %w", id, err)
|
|
}
|
|
|
|
// Mark issue as dirty for incremental export
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO dirty_issues (issue_id, marked_at)
|
|
VALUES (?, ?)
|
|
ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at
|
|
`, id, now)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark issue dirty for %s: %w", id, err)
|
|
}
|
|
}
|
|
|
|
// 4. Invalidate blocked issues cache since statuses changed (bd-5qim)
|
|
if err := s.invalidateBlockedCache(ctx, tx); err != nil {
|
|
return fmt.Errorf("failed to invalidate blocked cache: %w", err)
|
|
}
|
|
|
|
result.DeletedCount = deletedCount
|
|
return nil
|
|
}
|
|
|
|
// findAllDependentsRecursive finds all issues that depend on the given issues, recursively
|
|
func (s *SQLiteStorage) findAllDependentsRecursive(ctx context.Context, tx *sql.Tx, ids []string) (map[string]bool, error) {
|
|
result := make(map[string]bool)
|
|
for _, id := range ids {
|
|
result[id] = true
|
|
}
|
|
|
|
toProcess := make([]string, len(ids))
|
|
copy(toProcess, ids)
|
|
|
|
for len(toProcess) > 0 {
|
|
current := toProcess[0]
|
|
toProcess = toProcess[1:]
|
|
|
|
rows, err := tx.QueryContext(ctx,
|
|
`SELECT issue_id FROM dependencies WHERE depends_on_id = ?`, current)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var depID string
|
|
if err := rows.Scan(&depID); err != nil {
|
|
return nil, err
|
|
}
|
|
if !result[depID] {
|
|
result[depID] = true
|
|
toProcess = append(toProcess, depID)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|