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:
1618
.beads/issues.jsonl
1618
.beads/issues.jsonl
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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 = ?,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user