- Go module at github.com/felt-app/felt with go-libsql pinned to commit hash - Full directory structure per research recommendations (cmd/leaf, internal/*, frontend/) - Makefile with build, run, run-dev, test, frontend, all, clean targets - LibSQL database with WAL mode, foreign keys, and embedded migration runner - SvelteKit SPA stub served via go:embed - Package stubs for all internal packages (server, nats, store, auth, clock, etc.) - go build and go vet pass cleanly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
130 lines
3.8 KiB
Go
130 lines
3.8 KiB
Go
// 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
|
|
}
|