fix(cleanup): resolve CHECK constraint failure and add tombstone pruning

- Fix bd-tnsq: executeDelete now sets closed_at=NULL when creating
  tombstones, satisfying the CHECK constraint that requires
  closed_at IS NULL when status != 'closed'

- Fix bd-08ea: cleanup command now also prunes expired tombstones
  (older than 30 days) after converting closed issues to tombstones

- Add regression test for batch deletion of closed issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-14 00:37:34 -08:00
parent bb2e4f6fbf
commit a61ca252ae
5 changed files with 952 additions and 830 deletions

File diff suppressed because one or more lines are too long

View File

@@ -13,23 +13,26 @@ import (
var cleanupCmd = &cobra.Command{
Use: "cleanup",
Short: "Delete closed issues from database to free up space",
Long: `Delete closed issues from the database to reduce database size.
Short: "Delete closed issues and prune expired tombstones",
Long: `Delete closed issues and prune expired tombstones to reduce database size.
This command:
1. Converts closed issues to tombstones (soft delete)
2. Prunes expired tombstones (older than 30 days) from issues.jsonl
This command permanently removes closed issues from beads.db and issues.jsonl.
It does NOT remove temporary files - use 'bd clean' for that.
By default, deletes ALL closed issues. Use --older-than to only delete
issues closed before a certain date.
EXAMPLES:
Delete all closed issues:
Delete all closed issues and prune tombstones:
bd cleanup --force
Delete issues closed more than 30 days ago:
bd cleanup --older-than 30 --force
Preview what would be deleted:
Preview what would be deleted/pruned:
bd cleanup --dry-run
bd cleanup --older-than 90 --dry-run
@@ -40,7 +43,8 @@ SAFETY:
- Use --json for programmatic output
SEE ALSO:
bd clean Remove temporary git merge artifacts`,
bd clean Remove temporary git merge artifacts
bd compact Run compaction on issues`,
Run: func(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force")
dryRun, _ := cmd.Flags().GetBool("dry-run")
@@ -128,6 +132,37 @@ SEE ALSO:
// Use the existing batch deletion logic
deleteBatch(cmd, issueIDs, force, dryRun, cascade, jsonOutput, "cleanup")
// Also prune expired tombstones (bd-08ea)
// This runs after closed issues are converted to tombstones, cleaning up old ones
if dryRun {
// Preview what tombstones would be pruned
tombstoneResult, err := previewPruneTombstones()
if err != nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "Warning: failed to check tombstones: %v\n", err)
}
} else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 {
if !jsonOutput {
fmt.Printf("\nExpired tombstones that would be pruned: %d (older than %d days)\n",
tombstoneResult.PrunedCount, tombstoneResult.TTLDays)
}
}
} else if force {
// Actually prune expired tombstones
tombstoneResult, err := pruneExpiredTombstones()
if err != nil {
if !jsonOutput {
fmt.Fprintf(os.Stderr, "Warning: failed to prune expired tombstones: %v\n", err)
}
} else if tombstoneResult != nil && tombstoneResult.PrunedCount > 0 {
if !jsonOutput {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s Pruned %d expired tombstone(s) (older than %d days)\n",
green("✓"), tombstoneResult.PrunedCount, tombstoneResult.TTLDays)
}
}
}
},
}

View File

@@ -1076,6 +1076,58 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) {
}, nil
}
// previewPruneTombstones checks what tombstones would be pruned without modifying files.
// Used for dry-run mode in cleanup command (bd-08ea).
func previewPruneTombstones() (*TombstonePruneResult, error) {
beadsDir := filepath.Dir(dbPath)
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
// Check if issues.jsonl exists
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
return &TombstonePruneResult{}, nil
}
// Read all issues
// nolint:gosec // G304: issuesPath is controlled from beadsDir
file, err := os.Open(issuesPath)
if err != nil {
return nil, fmt.Errorf("failed to open issues.jsonl: %w", err)
}
defer file.Close()
var allIssues []*types.Issue
decoder := json.NewDecoder(file)
for {
var issue types.Issue
if err := decoder.Decode(&issue); err != nil {
if err.Error() == "EOF" {
break
}
// Skip corrupt lines
continue
}
allIssues = append(allIssues, &issue)
}
// Determine TTL
ttl := types.DefaultTombstoneTTL
ttlDays := int(ttl.Hours() / 24)
// Count expired tombstones
var prunedIDs []string
for _, issue := range allIssues {
if issue.IsExpired(ttl) {
prunedIDs = append(prunedIDs, issue.ID)
}
}
return &TombstonePruneResult{
PrunedCount: len(prunedIDs),
PrunedIDs: prunedIDs,
TTLDays: ttlDays,
}, nil
}
func init() {
compactCmd.Flags().BoolVar(&compactDryRun, "dry-run", false, "Preview without compacting")
compactCmd.Flags().IntVar(&compactTier, "tier", 1, "Compaction tier (1 or 2)")

View File

@@ -1202,12 +1202,15 @@ func (s *SQLiteStorage) executeDelete(ctx context.Context, tx *sql.Tx, inClause
_ = 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 = ?,

View File

@@ -3,6 +3,7 @@ package sqlite
import (
"context"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
@@ -299,6 +300,67 @@ func TestDeleteIssuesCreatesTombstones(t *testing.T) {
}
})
t.Run("batch deletion of closed issues creates tombstones (bd-tnsq)", func(t *testing.T) {
// Regression test: batch deletion of closed issues was failing with
// CHECK constraint: (status = 'closed') = (closed_at IS NOT NULL)
// because closed_at wasn't being set to NULL when creating tombstones
store := newTestStore(t, "file::memory:?mode=memory&cache=private")
now := time.Now()
closedAt := now.Add(-24 * time.Hour)
// Create closed issues (with closed_at set)
issue1 := &types.Issue{
ID: "bd-closed-10",
Title: "Closed Issue 1",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeBug,
ClosedAt: &closedAt,
}
issue2 := &types.Issue{
ID: "bd-closed-11",
Title: "Closed Issue 2",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeTask,
ClosedAt: &closedAt,
}
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
t.Fatalf("Failed to create closed issue1: %v", err)
}
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
t.Fatalf("Failed to create closed issue2: %v", err)
}
// Batch delete closed issues - this was failing before the fix
result, err := store.DeleteIssues(ctx, []string{"bd-closed-10", "bd-closed-11"}, false, true, false)
if err != nil {
t.Fatalf("DeleteIssues on closed issues failed: %v", err)
}
if result.DeletedCount != 2 {
t.Errorf("Expected 2 deletions, got %d", result.DeletedCount)
}
// Verify tombstones have closed_at = NULL (required by CHECK constraint)
tombstone1, _ := store.GetIssue(ctx, "bd-closed-10")
if tombstone1 == nil || tombstone1.Status != types.StatusTombstone {
t.Error("bd-closed-10 should be tombstone")
}
if tombstone1.ClosedAt != nil {
t.Error("bd-closed-10 tombstone should have closed_at = NULL")
}
tombstone2, _ := store.GetIssue(ctx, "bd-closed-11")
if tombstone2 == nil || tombstone2.Status != types.StatusTombstone {
t.Error("bd-closed-11 should be tombstone")
}
if tombstone2.ClosedAt != nil {
t.Error("bd-closed-11 tombstone should have closed_at = NULL")
}
})
t.Run("cascade deletion creates tombstones", func(t *testing.T) {
store := newTestStore(t, "file::memory:?mode=memory&cache=private")