// Package store provides SQLite storage for TUI local mode package store import ( "database/sql" "fmt" "os" "path/filepath" _ "modernc.org/sqlite" // SQLite driver ) // Store handles SQLite operations for the TUI type Store struct { db *sql.DB dbPath string } // RunInfo represents a local run from SQLite type RunInfo struct { RunID string ExperimentID string Name string Status string StartTime string EndTime *string PID *int64 Synced bool } // Metric represents a logged metric type Metric struct { Key string Value float64 Step int64 } // Param represents a logged parameter type Param struct { Key string Value string } // Open opens the SQLite database at the given path func Open(dbPath string) (*Store, error) { // Ensure directory exists dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create directory: %w", err) } db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } // Enable WAL mode for better concurrency if _, err := db.Exec("PRAGMA journal_mode=WAL;"); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to enable WAL: %w", err) } // Set synchronous mode for performance if _, err := db.Exec("PRAGMA synchronous=NORMAL;"); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to set synchronous: %w", err) } store := &Store{ db: db, dbPath: dbPath, } // Initialize schema if needed if err := store.initSchema(); err != nil { _ = db.Close() return nil, fmt.Errorf("failed to init schema: %w", err) } return store, nil } // Close closes the database connection func (s *Store) Close() error { if s.db != nil { // Checkpoint WAL before closing _, _ = s.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);") return s.db.Close() } return nil } // initSchema creates the required tables if they don't exist func (s *Store) initSchema() error { schema := ` CREATE TABLE IF NOT EXISTS ml_experiments ( experiment_id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT DEFAULT 'active' ); CREATE TABLE IF NOT EXISTS ml_runs ( run_id TEXT PRIMARY KEY, experiment_id TEXT NOT NULL, name TEXT, status TEXT NOT NULL, start_time DATETIME DEFAULT CURRENT_TIMESTAMP, end_time DATETIME, pid INTEGER, synced INTEGER DEFAULT 0, FOREIGN KEY (experiment_id) REFERENCES ml_experiments(experiment_id) ); CREATE TABLE IF NOT EXISTS ml_metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, key TEXT NOT NULL, value REAL NOT NULL, step INTEGER DEFAULT 0, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (run_id) REFERENCES ml_runs(run_id) ); CREATE TABLE IF NOT EXISTS ml_params ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES ml_runs(run_id) ); CREATE TABLE IF NOT EXISTS ml_tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES ml_runs(run_id) ); CREATE INDEX IF NOT EXISTS idx_runs_experiment ON ml_runs(experiment_id); CREATE INDEX IF NOT EXISTS idx_runs_status ON ml_runs(status); CREATE INDEX IF NOT EXISTS idx_metrics_run ON ml_metrics(run_id); CREATE INDEX IF NOT EXISTS idx_params_run ON ml_params(run_id); CREATE INDEX IF NOT EXISTS idx_tags_run ON ml_tags(run_id); ` _, err := s.db.Exec(schema) return err } // GetUnsyncedRuns returns all runs that haven't been synced to the server func (s *Store) GetUnsyncedRuns() ([]RunInfo, error) { rows, err := s.db.Query(` SELECT run_id, experiment_id, name, status, start_time, end_time, pid, synced FROM ml_runs WHERE synced = 0 ORDER BY start_time DESC `) if err != nil { return nil, err } defer rows.Close() var runs []RunInfo for rows.Next() { var r RunInfo var endTime sql.NullString var pid sql.NullInt64 err := rows.Scan(&r.RunID, &r.ExperimentID, &r.Name, &r.Status, &r.StartTime, &endTime, &pid, &r.Synced) if err != nil { return nil, err } if endTime.Valid { r.EndTime = &endTime.String } if pid.Valid { r.PID = &pid.Int64 } runs = append(runs, r) } return runs, rows.Err() } // GetRunsByExperiment returns all runs for a given experiment func (s *Store) GetRunsByExperiment(expID string) ([]RunInfo, error) { rows, err := s.db.Query(` SELECT run_id, experiment_id, name, status, start_time, end_time, pid, synced FROM ml_runs WHERE experiment_id = ? ORDER BY start_time DESC `, expID) if err != nil { return nil, err } defer rows.Close() var runs []RunInfo for rows.Next() { var r RunInfo var endTime sql.NullString var pid sql.NullInt64 err := rows.Scan(&r.RunID, &r.ExperimentID, &r.Name, &r.Status, &r.StartTime, &endTime, &pid, &r.Synced) if err != nil { return nil, err } if endTime.Valid { r.EndTime = &endTime.String } if pid.Valid { r.PID = &pid.Int64 } runs = append(runs, r) } return runs, rows.Err() } // MarkRunSynced marks a run as synced to the server func (s *Store) MarkRunSynced(runID string) error { _, err := s.db.Exec("UPDATE ml_runs SET synced = 1 WHERE run_id = ?", runID) return err } // GetRunMetrics returns all metrics for a run func (s *Store) GetRunMetrics(runID string) ([]Metric, error) { rows, err := s.db.Query(` SELECT key, value, step FROM ml_metrics WHERE run_id = ? ORDER BY step ASC, key ASC `, runID) if err != nil { return nil, err } defer rows.Close() var metrics []Metric for rows.Next() { var m Metric if err := rows.Scan(&m.Key, &m.Value, &m.Step); err != nil { return nil, err } metrics = append(metrics, m) } return metrics, rows.Err() } // GetRunParams returns all params for a run func (s *Store) GetRunParams(runID string) ([]Param, error) { rows, err := s.db.Query(` SELECT key, value FROM ml_params WHERE run_id = ? ORDER BY key ASC `, runID) if err != nil { return nil, err } defer rows.Close() var params []Param for rows.Next() { var p Param if err := rows.Scan(&p.Key, &p.Value); err != nil { return nil, err } params = append(params, p) } return params, rows.Err() } // DB returns the underlying database connection func (s *Store) DB() *sql.DB { return s.db }