Implement --no-db mode to avoid SQLite database corruption in scenarios
where the same .beads directory is accessed from multiple processes
(e.g., host + container, multiple containers).
Changes:
- Add in-memory storage backend (internal/storage/memory/memory.go)
- Implements full Storage interface using in-memory data structures
- Thread-safe with mutex protection for concurrent access
- Supports all core operations: issues, dependencies, labels, comments
- Add JSONL persistence layer (cmd/bd/nodb.go)
- initializeNoDbMode(): Load .beads/issues.jsonl on startup
- writeIssuesToJSONL(): Atomic write-back after each command
- detectPrefix(): Smart prefix detection with fallback hierarchy
1. .beads/nodb_prefix.txt (explicit config)
2. Common prefix from existing issues
3. Current directory name (fallback)
- Integrate --no-db flag into command flow (cmd/bd/main.go)
- Add global --no-db flag to all commands
- PersistentPreRun: Initialize memory storage from JSONL
- PersistentPostRun: Write memory back to JSONL atomically
- Skip daemon and SQLite initialization in --no-db mode
- Extract common writeJSONLAtomic() helper to eliminate duplication
- Update bd init for --no-db mode (cmd/bd/init.go)
- Create .beads/nodb_prefix.txt instead of SQLite database
- Create empty issues.jsonl file
- Display --no-db specific initialization message
Code Quality:
- Refactored atomic JSONL writes into shared writeJSONLAtomic() helper
- Used by both flushToJSONL (SQLite mode) and writeIssuesToJSONL (--no-db mode)
- Eliminates ~90 lines of code duplication
- Ensures consistent atomic write behavior across modes
Usage:
bd --no-db init -p myproject
bd --no-db create "Fix bug" --priority 1
bd --no-db list
bd --no-db update myproject-1 --status in_progress
Benefits:
- No SQLite corruption from concurrent access
- Container-safe: perfect for multi-mount scenarios
- Git-friendly: direct JSONL diffs work seamlessly
- Simple: no daemon, no WAL files, just JSONL
Test Results (go test ./...):
- ✓ github.com/steveyegge/beads: PASS
- ✗ github.com/steveyegge/beads/cmd/bd: 1 pre-existing failure (TestAutoFlushErrorHandling)
- ✓ github.com/steveyegge/beads/internal/compact: PASS
- ✗ github.com/steveyegge/beads/internal/rpc: 1 pre-existing failure (TestMemoryPressureDetection)
- ✓ github.com/steveyegge/beads/internal/storage/sqlite: PASS
- ✓ github.com/steveyegge/beads/internal/types: PASS
- ⚠ github.com/steveyegge/beads/internal/storage/memory: no tests yet
All test failures are pre-existing and unrelated to --no-db implementation.
The new --no-db mode has been manually tested and verified working.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
203 lines
5.0 KiB
Go
203 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/memory"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// initializeNoDbMode sets up in-memory storage from JSONL file
|
|
// This is called when --no-db flag is set
|
|
func initializeNoDbMode() error {
|
|
// Find .beads directory
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current directory: %w", err)
|
|
}
|
|
|
|
beadsDir := filepath.Join(cwd, ".beads")
|
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("no .beads directory found (hint: run 'bd init' first)")
|
|
}
|
|
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
// Create memory storage
|
|
memStore := memory.New(jsonlPath)
|
|
|
|
// Try to load from JSONL if it exists
|
|
if _, err := os.Stat(jsonlPath); err == nil {
|
|
issues, err := loadIssuesFromJSONL(jsonlPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load issues from %s: %w", jsonlPath, err)
|
|
}
|
|
|
|
if err := memStore.LoadFromIssues(issues); err != nil {
|
|
return fmt.Errorf("failed to load issues into memory: %w", err)
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: loaded %d issues from %s\n", len(issues), jsonlPath)
|
|
}
|
|
} else {
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: no existing %s, starting with empty database\n", jsonlPath)
|
|
}
|
|
}
|
|
|
|
// Detect and set prefix
|
|
prefix, err := detectPrefix(beadsDir, memStore)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect prefix: %w", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := memStore.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
|
return fmt.Errorf("failed to set prefix: %w", err)
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: using prefix '%s'\n", prefix)
|
|
}
|
|
|
|
// Set global store
|
|
store = memStore
|
|
return nil
|
|
}
|
|
|
|
// loadIssuesFromJSONL reads all issues from a JSONL file
|
|
func loadIssuesFromJSONL(path string) ([]*types.Issue, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
var issues []*types.Issue
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
|
|
// Skip empty lines
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
return nil, fmt.Errorf("line %d: %w", lineNum, err)
|
|
}
|
|
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return issues, nil
|
|
}
|
|
|
|
// detectPrefix detects the issue prefix to use in --no-db mode
|
|
// Priority:
|
|
// 1. .beads/nodb_prefix.txt file (if exists)
|
|
// 2. Common prefix from existing issues (if all share same prefix)
|
|
// 3. Current directory name (fallback)
|
|
func detectPrefix(beadsDir string, memStore *memory.MemoryStorage) (string, error) {
|
|
// Check for nodb_prefix.txt
|
|
prefixFile := filepath.Join(beadsDir, "nodb_prefix.txt")
|
|
if data, err := os.ReadFile(prefixFile); err == nil {
|
|
prefix := strings.TrimSpace(string(data))
|
|
if prefix != "" {
|
|
return prefix, nil
|
|
}
|
|
}
|
|
|
|
// Check existing issues for common prefix
|
|
issues := memStore.GetAllIssues()
|
|
if len(issues) > 0 {
|
|
// Extract prefix from first issue
|
|
firstPrefix := extractIssuePrefix(issues[0].ID)
|
|
|
|
// Check if all issues share the same prefix
|
|
allSame := true
|
|
for _, issue := range issues {
|
|
if extractIssuePrefix(issue.ID) != firstPrefix {
|
|
allSame = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if allSame && firstPrefix != "" {
|
|
return firstPrefix, nil
|
|
}
|
|
|
|
// If issues have mixed prefixes, we can't auto-detect
|
|
if !allSame {
|
|
return "", fmt.Errorf("issues have mixed prefixes, please create .beads/nodb_prefix.txt with the desired prefix")
|
|
}
|
|
}
|
|
|
|
// Fallback to directory name
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "bd", nil // Ultimate fallback
|
|
}
|
|
|
|
prefix := filepath.Base(cwd)
|
|
// Sanitize prefix (remove special characters, use only alphanumeric and hyphens)
|
|
prefix = strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
|
return r
|
|
}
|
|
if r >= 'A' && r <= 'Z' {
|
|
return r + ('a' - 'A') // Convert to lowercase
|
|
}
|
|
return -1 // Remove character
|
|
}, prefix)
|
|
|
|
if prefix == "" {
|
|
prefix = "bd"
|
|
}
|
|
|
|
return prefix, nil
|
|
}
|
|
|
|
// extractIssuePrefix extracts the prefix from an issue ID like "bd-123" -> "bd"
|
|
func extractIssuePrefix(issueID string) string {
|
|
parts := strings.SplitN(issueID, "-", 2)
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
return parts[0]
|
|
}
|
|
|
|
// writeIssuesToJSONL writes all issues from memory storage to JSONL file atomically
|
|
func writeIssuesToJSONL(memStore *memory.MemoryStorage, beadsDir string) error {
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
|
|
// Get all issues from memory storage
|
|
issues := memStore.GetAllIssues()
|
|
|
|
// Write atomically using common helper (handles temp file + rename + permissions)
|
|
if err := writeJSONLAtomic(jsonlPath, issues); err != nil {
|
|
return err
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: wrote %d issues to %s\n", len(issues), jsonlPath)
|
|
}
|
|
|
|
return nil
|
|
}
|