feat(tombstone): implement delete-to-tombstone and TTL expiration (bd-3b4, bd-olt)
Phase 1 of tombstone migration: bd delete now creates tombstones instead of hard-deleting issues. Key changes: - Add CreateTombstone() method to SQLiteStorage for soft-delete - Modify executeDelete() to create tombstones instead of removing rows - Add IsExpired() method with 30-day default TTL and clock skew grace - Fix deleted_at schema from TEXT to DATETIME for proper time scanning - Update delete.go to call CreateTombstone (single issue path) - Still writes to deletions.jsonl for backward compatibility (dual-write) - Dependencies are removed when creating tombstones - Tombstones are excluded from normal searches (bd-1bu) TTL constants: - DefaultTombstoneTTL: 30 days - MinTombstoneTTL: 7 days (safety floor) - ClockSkewGrace: 1 hour 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -74,11 +74,47 @@ func (i *Issue) ComputeContentHash() string {
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// DefaultTombstoneTTL is the default time-to-live for tombstones (30 days)
|
||||
const DefaultTombstoneTTL = 30 * 24 * time.Hour
|
||||
|
||||
// MinTombstoneTTL is the minimum allowed TTL (7 days) to prevent data loss
|
||||
const MinTombstoneTTL = 7 * 24 * time.Hour
|
||||
|
||||
// ClockSkewGrace is added to TTL to handle clock drift between machines
|
||||
const ClockSkewGrace = 1 * time.Hour
|
||||
|
||||
// IsTombstone returns true if the issue has been soft-deleted (bd-vw8)
|
||||
func (i *Issue) IsTombstone() bool {
|
||||
return i.Status == StatusTombstone
|
||||
}
|
||||
|
||||
// IsExpired returns true if the tombstone has exceeded its TTL.
|
||||
// Non-tombstone issues always return false.
|
||||
// ttl is the configured TTL duration; if zero, DefaultTombstoneTTL is used.
|
||||
func (i *Issue) IsExpired(ttl time.Duration) bool {
|
||||
// Non-tombstones never expire
|
||||
if !i.IsTombstone() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Tombstones without DeletedAt are not expired (safety: shouldn't happen in valid data)
|
||||
if i.DeletedAt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use default TTL if not specified
|
||||
if ttl == 0 {
|
||||
ttl = DefaultTombstoneTTL
|
||||
}
|
||||
|
||||
// Add clock skew grace period to the TTL
|
||||
effectiveTTL := ttl + ClockSkewGrace
|
||||
|
||||
// Check if the tombstone has exceeded its TTL
|
||||
expirationTime := i.DeletedAt.Add(effectiveTTL)
|
||||
return time.Now().After(expirationTime)
|
||||
}
|
||||
|
||||
// Validate checks if the issue has valid field values (built-in statuses only)
|
||||
func (i *Issue) Validate() error {
|
||||
return i.ValidateWithCustomStatuses(nil)
|
||||
|
||||
@@ -579,6 +579,213 @@ func TestSortPolicyIsValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExpired(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
issue Issue
|
||||
ttl time.Duration
|
||||
expired bool
|
||||
}{
|
||||
{
|
||||
name: "non-tombstone issue never expires",
|
||||
issue: Issue{
|
||||
ID: "test-1",
|
||||
Title: "Open issue",
|
||||
Status: StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: TypeTask,
|
||||
},
|
||||
ttl: 0,
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "closed issue never expires",
|
||||
issue: Issue{
|
||||
ID: "test-2",
|
||||
Title: "Closed issue",
|
||||
Status: StatusClosed,
|
||||
Priority: 2,
|
||||
IssueType: TypeTask,
|
||||
ClosedAt: timePtr(now),
|
||||
},
|
||||
ttl: 0,
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "tombstone without DeletedAt does not expire",
|
||||
issue: Issue{
|
||||
ID: "test-3",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: nil,
|
||||
},
|
||||
ttl: 0,
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "tombstone within default TTL (30 days)",
|
||||
issue: Issue{
|
||||
ID: "test-4",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-15 * 24 * time.Hour)), // 15 days ago
|
||||
},
|
||||
ttl: 0, // Use default TTL
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "tombstone past default TTL (30 days)",
|
||||
issue: Issue{
|
||||
ID: "test-5",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-35 * 24 * time.Hour)), // 35 days ago (past 30 days + 1 hour grace)
|
||||
},
|
||||
ttl: 0, // Use default TTL
|
||||
expired: true,
|
||||
},
|
||||
{
|
||||
name: "tombstone within custom TTL (7 days)",
|
||||
issue: Issue{
|
||||
ID: "test-6",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-3 * 24 * time.Hour)), // 3 days ago
|
||||
},
|
||||
ttl: 7 * 24 * time.Hour,
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "tombstone past custom TTL (7 days)",
|
||||
issue: Issue{
|
||||
ID: "test-7",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-9 * 24 * time.Hour)), // 9 days ago (past 7 days + 1 hour grace)
|
||||
},
|
||||
ttl: 7 * 24 * time.Hour,
|
||||
expired: true,
|
||||
},
|
||||
{
|
||||
name: "tombstone at exact TTL boundary (within grace period)",
|
||||
issue: Issue{
|
||||
ID: "test-8",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-30 * 24 * time.Hour)), // Exactly 30 days ago
|
||||
},
|
||||
ttl: 0, // Use default TTL (30 days + 1 hour grace)
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "tombstone just past TTL boundary (beyond grace period)",
|
||||
issue: Issue{
|
||||
ID: "test-9",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-(30*24*time.Hour + 2*time.Hour))), // 30 days + 2 hours ago
|
||||
},
|
||||
ttl: 0, // Use default TTL (30 days + 1 hour grace)
|
||||
expired: true,
|
||||
},
|
||||
{
|
||||
name: "tombstone within grace period",
|
||||
issue: Issue{
|
||||
ID: "test-10",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-(30*24*time.Hour + 30*time.Minute))), // 30 days + 30 minutes ago
|
||||
},
|
||||
ttl: 0, // Use default TTL (30 days + 1 hour grace)
|
||||
expired: false,
|
||||
},
|
||||
{
|
||||
name: "tombstone with MinTombstoneTTL (7 days)",
|
||||
issue: Issue{
|
||||
ID: "test-11",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-10 * 24 * time.Hour)), // 10 days ago
|
||||
},
|
||||
ttl: MinTombstoneTTL, // 7 days
|
||||
expired: true,
|
||||
},
|
||||
{
|
||||
name: "tombstone with very short TTL (1 hour)",
|
||||
issue: Issue{
|
||||
ID: "test-12",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(-3 * time.Hour)), // 3 hours ago
|
||||
},
|
||||
ttl: 1 * time.Hour, // 1 hour + 1 hour grace = 2 hours total
|
||||
expired: true,
|
||||
},
|
||||
{
|
||||
name: "tombstone deleted in the future (clock skew)",
|
||||
issue: Issue{
|
||||
ID: "test-13",
|
||||
Title: "(deleted)",
|
||||
Status: StatusTombstone,
|
||||
Priority: 0,
|
||||
IssueType: TypeTask,
|
||||
DeletedAt: timePtr(now.Add(1 * time.Hour)), // 1 hour in the future
|
||||
},
|
||||
ttl: 7 * 24 * time.Hour,
|
||||
expired: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.issue.IsExpired(tt.ttl)
|
||||
if got != tt.expired {
|
||||
t.Errorf("Issue.IsExpired(%v) = %v, want %v", tt.ttl, got, tt.expired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTombstoneTTLConstants(t *testing.T) {
|
||||
// Test that constants have expected values
|
||||
if DefaultTombstoneTTL != 30*24*time.Hour {
|
||||
t.Errorf("DefaultTombstoneTTL = %v, want %v", DefaultTombstoneTTL, 30*24*time.Hour)
|
||||
}
|
||||
if MinTombstoneTTL != 7*24*time.Hour {
|
||||
t.Errorf("MinTombstoneTTL = %v, want %v", MinTombstoneTTL, 7*24*time.Hour)
|
||||
}
|
||||
if ClockSkewGrace != 1*time.Hour {
|
||||
t.Errorf("ClockSkewGrace = %v, want %v", ClockSkewGrace, 1*time.Hour)
|
||||
}
|
||||
|
||||
// Test that MinTombstoneTTL is less than DefaultTombstoneTTL
|
||||
if MinTombstoneTTL >= DefaultTombstoneTTL {
|
||||
t.Errorf("MinTombstoneTTL (%v) should be less than DefaultTombstoneTTL (%v)", MinTombstoneTTL, DefaultTombstoneTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func intPtr(i int) *int {
|
||||
|
||||
Reference in New Issue
Block a user