// Package database provides SQLite database access with logging. package database import ( "context" "database/sql" "fmt" "log/slog" "os" "path/filepath" _ "github.com/mattn/go-sqlite3" // SQLite driver "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/logger" ) // dataDirPermissions is the file permission for the data directory. const dataDirPermissions = 0o750 // Params contains dependencies for Database. type Params struct { fx.In Logger *logger.Logger Config *config.Config } // Database wraps sql.DB with logging and helper methods. type Database struct { database *sql.DB log *slog.Logger params *Params } // New creates a new Database instance. func New(lifecycle fx.Lifecycle, params Params) (*Database, error) { database := &Database{ log: params.Logger.Get(), params: ¶ms, } // For testing, if lifecycle is nil, connect immediately if lifecycle == nil { err := database.connect(context.Background()) if err != nil { return nil, err } return database, nil } lifecycle.Append(fx.Hook{ OnStart: func(ctx context.Context) error { return database.connect(ctx) }, OnStop: func(_ context.Context) error { return database.close() }, }) return database, nil } // DB returns the underlying sql.DB for direct access. func (d *Database) DB() *sql.DB { return d.database } // Exec executes a query with logging. func (d *Database) Exec( ctx context.Context, query string, args ...any, ) (sql.Result, error) { d.log.Debug("database exec", "query", query, "args", args) result, err := d.database.ExecContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("exec failed: %w", err) } return result, nil } // QueryRow executes a query that returns a single row. func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row { d.log.Debug("database query row", "query", query, "args", args) return d.database.QueryRowContext(ctx, query, args...) } // Query executes a query that returns multiple rows. func (d *Database) Query( ctx context.Context, query string, args ...any, ) (*sql.Rows, error) { d.log.Debug("database query", "query", query, "args", args) rows, err := d.database.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } return rows, nil } // BeginTx starts a new transaction. func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { d.log.Debug("database begin transaction") transaction, err := d.database.BeginTx(ctx, opts) if err != nil { return nil, fmt.Errorf("begin transaction failed: %w", err) } return transaction, nil } // Path returns the database file path. func (d *Database) Path() string { return d.params.Config.DatabasePath() } func (d *Database) connect(ctx context.Context) error { dbPath := d.params.Config.DatabasePath() // Ensure data directory exists dir := filepath.Dir(dbPath) err := os.MkdirAll(dir, dataDirPermissions) if err != nil { return fmt.Errorf("failed to create data directory: %w", err) } // Open database with WAL mode and foreign keys dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on" database, err := sql.Open("sqlite3", dsn) if err != nil { return fmt.Errorf("failed to open database: %w", err) } // Test connection err = database.PingContext(ctx) if err != nil { return fmt.Errorf("failed to ping database: %w", err) } d.database = database d.log.Info("database connected", "path", dbPath) // Run migrations err = d.migrate(ctx) if err != nil { return fmt.Errorf("failed to run migrations: %w", err) } return nil } func (d *Database) close() error { if d.database != nil { d.log.Info("closing database connection") err := d.database.Close() if err != nil { return fmt.Errorf("failed to close database: %w", err) } } return nil }