fix: read operations no longer modify database file (GH#804)

Add SQLite read-only mode for commands that only query data (list, ready,
show, stats, blocked, count, search, graph, duplicates, comments, export).

Changes:
- Add NewReadOnly() and NewReadOnlyWithTimeout() to sqlite package
  - Opens with mode=ro to prevent any file writes
  - Skips WAL pragma, schema init, and migrations
  - Skips WAL checkpoint on Close()
- Update main.go to detect read-only commands and use appropriate opener
- Skip auto-migrate, FlushManager, and auto-import for read-only commands
- Add tests verifying file mtime is unchanged after read operations

This fixes the issue where file watchers (like beads-ui) would go into
infinite loops because bd list/show/ready modified the database file.

🤖 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-30 16:47:38 -08:00
parent 994512654c
commit 7d4e8e2db9
3 changed files with 315 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ type SQLiteStorage struct {
closed atomic.Bool // Tracks whether Close() has been called
connStr string // Connection string for reconnection
busyTimeout time.Duration
readOnly bool // True if opened in read-only mode (GH#804)
freshness *FreshnessChecker // Optional freshness checker for daemon mode
reconnectMu sync.RWMutex // Protects reconnection and db access (GH#607)
}
@@ -203,16 +204,88 @@ func NewWithTimeout(ctx context.Context, path string, busyTimeout time.Duration)
return storage, nil
}
// NewReadOnly opens an existing database in read-only mode.
// This prevents any modification to the database file, including:
// - WAL journal mode changes
// - Schema/migration updates
// - WAL checkpointing on close
//
// Use this for read-only commands (list, ready, show, stats, etc.) to avoid
// triggering file watchers. See GH#804.
//
// Returns an error if the database doesn't exist (unlike New which creates it).
func NewReadOnly(ctx context.Context, path string) (*SQLiteStorage, error) {
return NewReadOnlyWithTimeout(ctx, path, 30*time.Second)
}
// NewReadOnlyWithTimeout opens an existing database in read-only mode with configurable timeout.
func NewReadOnlyWithTimeout(ctx context.Context, path string, busyTimeout time.Duration) (*SQLiteStorage, error) {
// Read-only mode doesn't make sense for in-memory databases
if path == ":memory:" || (strings.HasPrefix(path, "file:") && strings.Contains(path, "mode=memory")) {
return nil, fmt.Errorf("read-only mode not supported for in-memory databases")
}
// Check that the database file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("database does not exist: %s", path)
}
// Convert timeout to milliseconds for SQLite pragma
timeoutMs := int64(busyTimeout / time.Millisecond)
// Build read-only connection string with mode=ro
// This prevents any writes to the database file
connStr := fmt.Sprintf("file:%s?mode=ro&_pragma=foreign_keys(ON)&_pragma=busy_timeout(%d)&_time_format=sqlite", path, timeoutMs)
db, err := sql.Open("sqlite3", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open database read-only: %w", err)
}
// Read-only connections don't need a large pool
db.SetMaxOpenConns(2)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
// Test connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
// Skip schema initialization and migrations - we're read-only
// The database must already be properly initialized
// Convert to absolute path for consistency
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path: %w", err)
}
return &SQLiteStorage{
db: db,
dbPath: absPath,
connStr: connStr,
busyTimeout: busyTimeout,
readOnly: true,
}, nil
}
// Close closes the database connection.
// It checkpoints the WAL to ensure all writes are flushed to the main database file.
// For read-write connections, it checkpoints the WAL to ensure all writes
// are flushed to the main database file.
// For read-only connections (GH#804), it skips checkpointing to avoid file modifications.
func (s *SQLiteStorage) Close() error {
s.closed.Store(true)
// Acquire write lock to prevent racing with reconnect() (GH#607)
s.reconnectMu.Lock()
defer s.reconnectMu.Unlock()
// Checkpoint WAL to ensure all writes are persisted to the main database file.
// Without this, writes may be stranded in the WAL and lost between CLI invocations.
_, _ = s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
// Only checkpoint for read-write connections (GH#804)
// Read-only connections should not modify the database file at all.
if !s.readOnly {
// Checkpoint WAL to ensure all writes are persisted to the main database file.
// Without this, writes may be stranded in the WAL and lost between CLI invocations.
_, _ = s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
}
return s.db.Close()
}