// Package store is geniusrun'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 geniusrun 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) } // Migrations 0023, 0024, and 0025 rebuild tables that have incoming // foreign keys (kind_assignments/workout_type_paces reference // workout_kinds; sync_state is referenced by activities/sync_runs). // These require temporary FK disable in autocommit mode (before the // transaction begins) so the DROP TABLE succeeds; a mid-transaction // PRAGMA is a no-op with modernc.org/sqlite. tableRebuildMigrations := map[string]bool{ "0023_profile_user_scoped.sql": true, "0024_workout_kinds_user_scoped.sql": true, "0025_sync_state_user_scoped.sql": true, } needsFKToggle := tableRebuildMigrations[name] if needsFKToggle { if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { return fmt.Errorf("disable foreign keys before migration %s: %w", name, err) } } tx, err := db.Begin() if err != nil { if needsFKToggle { db.Exec(`PRAGMA foreign_keys = ON`) } return fmt.Errorf("begin migration tx for %s: %w", name, err) } if _, err := tx.Exec(string(content)); err != nil { tx.Rollback() if needsFKToggle { db.Exec(`PRAGMA foreign_keys = ON`) } return fmt.Errorf("apply migration %s: %w", name, err) } if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil { tx.Rollback() if needsFKToggle { db.Exec(`PRAGMA foreign_keys = ON`) } return fmt.Errorf("record migration %s: %w", name, err) } if err := tx.Commit(); err != nil { if needsFKToggle { db.Exec(`PRAGMA foreign_keys = ON`) } return fmt.Errorf("commit migration %s: %w", name, err) } if needsFKToggle { if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { return fmt.Errorf("enable foreign keys after migration %s: %w", name, err) } } } return nil }