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)