174 lines
3.4 KiB
Go
174 lines
3.4 KiB
Go
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
|
|
}
|
|
|
|
baseDir := strings.TrimSpace(cfg.BaseDir)
|
|
if baseDir == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err == nil && strings.TrimSpace(home) != "" {
|
|
baseDir = filepath.Join(home, ".local", "share", "goaichat")
|
|
}
|
|
}
|
|
|
|
if baseDir == "" {
|
|
username := strings.TrimSpace(cfg.Username)
|
|
if username == "" {
|
|
username = strings.TrimSpace(os.Getenv("USER"))
|
|
if username == "" {
|
|
username = "default"
|
|
}
|
|
}
|
|
baseDir = filepath.Join(os.TempDir(), "goaichat", username)
|
|
}
|
|
|
|
return filepath.Join(baseDir, "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
|
|
}
|