// Package store provides database access for the Felt tournament engine. // // It manages the LibSQL (SQLite-compatible) connection, runs embedded migrations // on startup, and provides the database handle for use by other packages. package store import ( "database/sql" "fmt" "log" "os" "path/filepath" _ "github.com/tursodatabase/go-libsql" ) // DB wraps the underlying sql.DB connection with Felt-specific configuration. type DB struct { *sql.DB dataDir string } // Open opens (or creates) a LibSQL database in the given data directory. // It enables WAL mode and foreign key enforcement, then runs any pending // migrations automatically. func Open(dataDir string, devMode bool) (*DB, error) { // Ensure data directory exists if err := os.MkdirAll(dataDir, 0750); err != nil { return nil, fmt.Errorf("store: create data dir: %w", err) } dbPath := filepath.Join(dataDir, "felt.db") sqlDB, err := sql.Open("libsql", "file:"+dbPath) if err != nil { return nil, fmt.Errorf("store: open database: %w", err) } // Verify connection if err := sqlDB.Ping(); err != nil { sqlDB.Close() return nil, fmt.Errorf("store: ping database: %w", err) } // Set PRAGMAs for performance and correctness. // go-libsql: journal_mode returns a row, use QueryRow. // foreign_keys and busy_timeout are set then verified with separate queries. var walMode string if err := sqlDB.QueryRow("PRAGMA journal_mode=WAL").Scan(&walMode); err != nil { sqlDB.Close() return nil, fmt.Errorf("store: set journal_mode: %w", err) } log.Printf("store: journal_mode=%s", walMode) // These PRAGMAs are setters that don't return rows in go-libsql. // We set them, then verify with a separate getter query. if _, err := execPragma(sqlDB, "PRAGMA foreign_keys=ON"); err != nil { sqlDB.Close() return nil, fmt.Errorf("store: set foreign_keys: %w", err) } if _, err := execPragma(sqlDB, "PRAGMA busy_timeout=5000"); err != nil { sqlDB.Close() return nil, fmt.Errorf("store: set busy_timeout: %w", err) } // Verify settings var fk, bt string if err := sqlDB.QueryRow("PRAGMA foreign_keys").Scan(&fk); err == nil { log.Printf("store: foreign_keys=%s", fk) } if err := sqlDB.QueryRow("PRAGMA busy_timeout").Scan(&bt); err == nil { log.Printf("store: busy_timeout=%s", bt) } log.Printf("store: opened database at %s", dbPath) db := &DB{ DB: sqlDB, dataDir: dataDir, } // Run migrations if err := RunMigrations(sqlDB, devMode); err != nil { sqlDB.Close() return nil, fmt.Errorf("store: run migrations: %w", err) } return db, nil } // execPragma tries Exec first; if that fails with "Execute returned rows", // it falls back to QueryRow. go-libsql is inconsistent about which PRAGMAs // return rows vs which don't. func execPragma(db *sql.DB, pragma string) (string, error) { // Try Exec first (works for pure setters) _, err := db.Exec(pragma) if err == nil { return "", nil } // If Exec fails because it returns rows, use QueryRow var result string if qErr := db.QueryRow(pragma).Scan(&result); qErr != nil { // Return the original error if QueryRow also fails return "", err } return result, nil } // DataDir returns the data directory path. func (db *DB) DataDir() string { return db.dataDir } // OperatorCount returns the number of operators in the database. // Used by the first-run setup check. func (db *DB) OperatorCount() (int, error) { var count int err := db.QueryRow("SELECT COUNT(*) FROM operators").Scan(&count) return count, err } // NeedsSetup returns true if there are zero operators and the system // needs initial admin setup. In dev mode, this is handled by the dev // seed migration, so it always returns false. func (db *DB) NeedsSetup() (bool, error) { count, err := db.OperatorCount() if err != nil { return false, err } return count == 0, nil }