package storage import ( "database/sql" "errors" "fmt" "io/fs" "os" "path/filepath" "runtime" "sort" "strings" "sync" _ "modernc.org/sqlite" "github.com/stig/goaichat/internal/config" ) // Manager handles access to the SQLite database storing chat sessions and metadata. type Manager struct { path string cfg config.StorageConfig mu sync.Mutex db *sql.DB } // NewManager constructs a Manager and ensures the data directory exists. func NewManager(cfg config.Config) (*Manager, error) { path, err := resolvePath(cfg.Storage) if err != nil { return nil, err } if err := ensureDir(filepath.Dir(path)); err != nil { return nil, err } return &Manager{path: path, cfg: cfg.Storage}, nil } // Open lazily opens the SQLite database connection. func (m *Manager) Open() (*sql.DB, error) { m.mu.Lock() defer m.mu.Unlock() if m.db != nil { return m.db, nil } dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_busy_timeout=5000", m.path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } if err := db.Ping(); err != nil { _ = db.Close() return nil, fmt.Errorf("ping sqlite: %w", err) } if err := applyMigrations(db); err != nil { _ = db.Close() return nil, err } m.db = db return db, nil } // Close releases the underlying database connection. func (m *Manager) Close() error { m.mu.Lock() defer m.mu.Unlock() if m.db == nil { return nil } err := m.db.Close() m.db = nil return err } func resolvePath(cfg config.StorageConfig) (string, error) { if strings.TrimSpace(cfg.Path) != "" { return cfg.Path, nil } username := cfg.Username if username == "" { username = os.Getenv("USER") if username == "" { username = "default" } } base := filepath.Join("/var/log/goaichat", username) if cfg.BaseDir != "" { base = cfg.BaseDir } return filepath.Join(base, "goaichat.db"), nil } func ensureDir(dir string) error { if dir == "" { return errors.New("directory path cannot be empty") } if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("create data directory: %w", err) } if runtime.GOOS != "windows" { if err := os.Chmod(dir, 0o700); err != nil { return fmt.Errorf("chmod data directory: %w", err) } } return nil } func applyMigrations(db *sql.DB) error { migrationsFS := os.DirFS("internal/storage/migrations") entries, err := fs.ReadDir(migrationsFS, ".") if err != nil { return fmt.Errorf("read migrations: %w", err) } var files []string for _, entry := range entries { if entry.IsDir() { continue } if filepath.Ext(entry.Name()) != ".sql" { continue } files = append(files, entry.Name()) } sort.Strings(files) tx, err := db.Begin() if err != nil { return fmt.Errorf("begin migration tx: %w", err) } for _, name := range files { data, readErr := fs.ReadFile(migrationsFS, name) if readErr != nil { _ = tx.Rollback() return fmt.Errorf("read migration %s: %w", name, readErr) } if _, execErr := tx.Exec(string(data)); execErr != nil { _ = tx.Rollback() return fmt.Errorf("apply migration %s: %w", name, execErr) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migrations: %w", err) } return nil }