feat(storage): add --backend flag for Dolt backend selection

Phase 2 of Dolt integration - enables runtime backend selection:

- Add --backend flag to bd init (sqlite|dolt)
- Create storage factory for backend instantiation
- Update daemon and main.go to use factory with config detection
- Update database discovery to find Dolt backends via metadata.json
- Fix Dolt schema init to split statements for MySQL compatibility
- Add ReadOnly mode to skip schema init for read-only commands

Usage: bd init --backend dolt --prefix myproject

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-14 21:42:31 -08:00
committed by gastown/crew/dennis
parent e861a667fc
commit 669ea40684
8 changed files with 1939 additions and 1299 deletions

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
@@ -52,6 +53,7 @@ type Config struct {
CommitterEmail string // Git-style committer email
Remote string // Default remote name (e.g., "origin")
Database string // Database name within Dolt (default: "beads")
ReadOnly bool // Open in read-only mode (skip schema init)
}
// New creates a new Dolt storage backend
@@ -85,8 +87,25 @@ func New(ctx context.Context, cfg *Config) (*DoltStore, error) {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
// Build Dolt connection string
// Format: file:///path/to/db?commitname=Name&commitemail=email&database=dbname
// First, connect without specifying a database to create it if needed
initConnStr := fmt.Sprintf(
"file://%s?commitname=%s&commitemail=%s",
cfg.Path, cfg.CommitterName, cfg.CommitterEmail)
initDB, err := sql.Open("dolt", initConnStr)
if err != nil {
return nil, fmt.Errorf("failed to open Dolt for initialization: %w", err)
}
// Create the database if it doesn't exist
_, err = initDB.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", cfg.Database))
if err != nil {
initDB.Close()
return nil, fmt.Errorf("failed to create database: %w", err)
}
initDB.Close()
// Now connect with the database specified
connStr := fmt.Sprintf(
"file://%s?commitname=%s&commitemail=%s&database=%s",
cfg.Path, cfg.CommitterName, cfg.CommitterEmail, cfg.Database)
@@ -121,11 +140,14 @@ func New(ctx context.Context, cfg *Config) (*DoltStore, error) {
committerEmail: cfg.CommitterEmail,
remote: cfg.Remote,
branch: "main",
readOnly: cfg.ReadOnly,
}
// Initialize schema
if err := store.initSchema(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize schema: %w", err)
// Initialize schema (skip for read-only mode)
if !cfg.ReadOnly {
if err := store.initSchema(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
}
return store, nil
@@ -133,14 +155,34 @@ func New(ctx context.Context, cfg *Config) (*DoltStore, error) {
// initSchema creates all tables if they don't exist
func (s *DoltStore) initSchema(ctx context.Context) error {
// Execute schema creation
if _, err := s.db.ExecContext(ctx, schema); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
// Execute schema creation - split into individual statements
// because MySQL/Dolt doesn't support multiple statements in one Exec
for _, stmt := range splitStatements(schema) {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
// Skip pure comment-only statements, but execute statements that start with comments
if isOnlyComments(stmt) {
continue
}
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to create schema: %w\nStatement: %s", err, truncateForError(stmt))
}
}
// Insert default config values
if _, err := s.db.ExecContext(ctx, defaultConfig); err != nil {
return fmt.Errorf("failed to insert default config: %w", err)
for _, stmt := range splitStatements(defaultConfig) {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if isOnlyComments(stmt) {
continue
}
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to insert default config: %w", err)
}
}
// Create views
@@ -154,6 +196,74 @@ func (s *DoltStore) initSchema(ctx context.Context) error {
return nil
}
// splitStatements splits a SQL script into individual statements
func splitStatements(script string) []string {
var statements []string
var current strings.Builder
inString := false
stringChar := byte(0)
for i := 0; i < len(script); i++ {
c := script[i]
if inString {
current.WriteByte(c)
if c == stringChar && (i == 0 || script[i-1] != '\\') {
inString = false
}
continue
}
if c == '\'' || c == '"' || c == '`' {
inString = true
stringChar = c
current.WriteByte(c)
continue
}
if c == ';' {
stmt := strings.TrimSpace(current.String())
if stmt != "" {
statements = append(statements, stmt)
}
current.Reset()
continue
}
current.WriteByte(c)
}
// Handle last statement without semicolon
stmt := strings.TrimSpace(current.String())
if stmt != "" {
statements = append(statements, stmt)
}
return statements
}
// truncateForError truncates a string for use in error messages
func truncateForError(s string) string {
if len(s) > 100 {
return s[:100] + "..."
}
return s
}
// isOnlyComments returns true if the statement contains only SQL comments
func isOnlyComments(stmt string) bool {
lines := strings.Split(stmt, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "--") {
continue
}
// Found a non-comment, non-empty line
return false
}
return true
}
// Close closes the database connection
func (s *DoltStore) Close() error {
s.closed.Store(true)

View File

@@ -0,0 +1,86 @@
// Package factory provides functions for creating storage backends based on configuration.
package factory
import (
"context"
"fmt"
"path/filepath"
"time"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/dolt"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
// Options configures how the storage backend is opened
type Options struct {
ReadOnly bool
LockTimeout time.Duration
}
// New creates a storage backend based on the backend type.
// For SQLite, path should be the full path to the .db file.
// For Dolt, path should be the directory containing the Dolt database.
func New(ctx context.Context, backend, path string) (storage.Storage, error) {
return NewWithOptions(ctx, backend, path, Options{})
}
// NewWithOptions creates a storage backend with the specified options.
func NewWithOptions(ctx context.Context, backend, path string, opts Options) (storage.Storage, error) {
switch backend {
case configfile.BackendSQLite, "":
if opts.ReadOnly {
if opts.LockTimeout > 0 {
return sqlite.NewReadOnlyWithTimeout(ctx, path, opts.LockTimeout)
}
return sqlite.NewReadOnly(ctx, path)
}
if opts.LockTimeout > 0 {
return sqlite.NewWithTimeout(ctx, path, opts.LockTimeout)
}
return sqlite.New(ctx, path)
case configfile.BackendDolt:
return dolt.New(ctx, &dolt.Config{Path: path, ReadOnly: opts.ReadOnly})
default:
return nil, fmt.Errorf("unknown storage backend: %s (supported: sqlite, dolt)", backend)
}
}
// NewFromConfig creates a storage backend based on the metadata.json configuration.
// beadsDir is the path to the .beads directory.
func NewFromConfig(ctx context.Context, beadsDir string) (storage.Storage, error) {
return NewFromConfigWithOptions(ctx, beadsDir, Options{})
}
// NewFromConfigWithOptions creates a storage backend with options from metadata.json.
func NewFromConfigWithOptions(ctx context.Context, beadsDir string, opts Options) (storage.Storage, error) {
cfg, err := configfile.Load(beadsDir)
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
if cfg == nil {
cfg = configfile.DefaultConfig()
}
backend := cfg.GetBackend()
switch backend {
case configfile.BackendSQLite:
return NewWithOptions(ctx, backend, cfg.DatabasePath(beadsDir), opts)
case configfile.BackendDolt:
// For Dolt, use a subdirectory to store the Dolt database
doltPath := filepath.Join(beadsDir, "dolt")
return NewWithOptions(ctx, backend, doltPath, opts)
default:
return nil, fmt.Errorf("unknown storage backend in config: %s", backend)
}
}
// GetBackendFromConfig returns the backend type from metadata.json
func GetBackendFromConfig(beadsDir string) string {
cfg, err := configfile.Load(beadsDir)
if err != nil || cfg == nil {
return configfile.BackendSQLite
}
return cfg.GetBackend()
}