Fix critical TOCTOU bug where concurrent operations could lose dirty issue tracking, causing data loss in incremental exports. Also fixes bug where export with filters would incorrectly clear all dirty issues. The Problem: 1. GetDirtyIssues() returns [bd-1, bd-2] 2. Concurrent CRUD marks bd-3 dirty 3. Export writes bd-1, bd-2 4. ClearDirtyIssues() deletes ALL (including bd-3) 5. Result: bd-3 never gets exported! The Fix: - Add ClearDirtyIssuesByID() that only clears specific issue IDs - Track which issues were actually exported - Clear only those specific IDs, not all dirty issues - Fixes both race condition and filter export bug Changes: - internal/storage/sqlite/dirty.go: * Add ClearDirtyIssuesByID() method * Add warning to ClearDirtyIssues() about race condition - internal/storage/storage.go: * Add ClearDirtyIssuesByID to interface - cmd/bd/main.go: * Update auto-flush to use ClearDirtyIssuesByID() - cmd/bd/export.go: * Track exported issue IDs * Use ClearDirtyIssuesByID() instead of ClearDirtyIssues() Testing: - Created test-1, test-2, test-3 (all dirty) - Updated test-2 to in_progress - Exported with --status open filter (exports only test-1, test-3) - Verified only test-2 remains dirty ✓ - All existing tests pass ✓ Impact: - Race condition eliminated - concurrent operations are safe - Export with filters now works correctly - No data loss from competing writes Closes bd-52, bd-53 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
74 lines
2.7 KiB
Go
74 lines
2.7 KiB
Go
// Package storage defines the interface for issue storage backends.
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// Storage defines the interface for issue storage backends
|
|
type Storage interface {
|
|
// Issues
|
|
CreateIssue(ctx context.Context, issue *types.Issue, actor string) error
|
|
GetIssue(ctx context.Context, id string) (*types.Issue, error)
|
|
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
|
|
CloseIssue(ctx context.Context, id string, reason string, actor string) error
|
|
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error)
|
|
|
|
// Dependencies
|
|
AddDependency(ctx context.Context, dep *types.Dependency, actor string) error
|
|
RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error
|
|
GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error)
|
|
GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error)
|
|
GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error)
|
|
GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error)
|
|
GetDependencyTree(ctx context.Context, issueID string, maxDepth int) ([]*types.TreeNode, error)
|
|
DetectCycles(ctx context.Context) ([][]*types.Issue, error)
|
|
|
|
// Labels
|
|
AddLabel(ctx context.Context, issueID, label, actor string) error
|
|
RemoveLabel(ctx context.Context, issueID, label, actor string) error
|
|
GetLabels(ctx context.Context, issueID string) ([]string, error)
|
|
GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error)
|
|
|
|
// Ready Work & Blocking
|
|
GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error)
|
|
GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error)
|
|
|
|
// Events
|
|
AddComment(ctx context.Context, issueID, actor, comment string) error
|
|
GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error)
|
|
|
|
// Statistics
|
|
GetStatistics(ctx context.Context) (*types.Statistics, error)
|
|
|
|
// Dirty tracking (for incremental JSONL export)
|
|
GetDirtyIssues(ctx context.Context) ([]string, error)
|
|
ClearDirtyIssues(ctx context.Context) error // WARNING: Race condition (bd-52), use ClearDirtyIssuesByID
|
|
ClearDirtyIssuesByID(ctx context.Context, issueIDs []string) error
|
|
|
|
// Config
|
|
SetConfig(ctx context.Context, key, value string) error
|
|
GetConfig(ctx context.Context, key string) (string, error)
|
|
|
|
// Lifecycle
|
|
Close() error
|
|
}
|
|
|
|
// Config holds database configuration
|
|
type Config struct {
|
|
Backend string // "sqlite" or "postgres"
|
|
|
|
// SQLite config
|
|
Path string // database file path
|
|
|
|
// PostgreSQL config
|
|
Host string
|
|
Port int
|
|
Database string
|
|
User string
|
|
Password string
|
|
SSLMode string
|
|
}
|