// Package store is smartrun's SQLite persistence layer: activities, laps, // per-second samples, workout kind rule config, and classification history. package store import ( "database/sql" "embed" "fmt" "io/fs" "sort" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationsFS embed.FS // DB wraps a *sql.DB opened against a smartrun SQLite database file, with // migrations already applied. type DB struct { *sql.DB } // Open opens (creating if needed) the SQLite database at path and applies // any migrations that haven't run yet. func Open(path string) (*DB, error) { sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)") if err != nil { return nil, fmt.Errorf("open sqlite database: %w", err) } // SQLite only supports one writer at a time; a single connection avoids // "database is locked" errors under any concurrent access from the app. sqlDB.SetMaxOpenConns(1) db := &DB{DB: sqlDB} if err := db.migrate(); err != nil { sqlDB.Close() return nil, err } return db, nil } func (db *DB) migrate() error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )`); err != nil { return fmt.Errorf("create schema_migrations table: %w", err) } applied := make(map[string]bool) rows, err := db.Query(`SELECT filename FROM schema_migrations`) if err != nil { return fmt.Errorf("query applied migrations: %w", err) } for rows.Next() { var name string if err := rows.Scan(&name); err != nil { rows.Close() return fmt.Errorf("scan applied migration: %w", err) } applied[name] = true } rows.Close() entries, err := fs.Glob(migrationsFS, "migrations/*.sql") if err != nil { return fmt.Errorf("glob migrations: %w", err) } sort.Strings(entries) for _, entry := range entries { name := entry[len("migrations/"):] if applied[name] { continue } content, err := migrationsFS.ReadFile(entry) if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } tx, err := db.Begin() if err != nil { return fmt.Errorf("begin migration tx for %s: %w", name, err) } if _, err := tx.Exec(string(content)); err != nil { tx.Rollback() return fmt.Errorf("apply migration %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { tx.Rollback() return fmt.Errorf("record migration %s: %w", name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", name, err) } } return nil }