package database
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/golang-migrate/migrate/v4"
migratesqlite "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jmoiron/sqlx"
"go.bigb.es/auxilia/culpa"
_ "modernc.org/sqlite" // register the "sqlite" database/sql driver
"sourcecraft.dev/bigbes/lethe/internal/config"
)
// Database is the steward-managed SQLite storage steward. Other services
// inject *Database and read .DB. Constructor is zero-value; lifecycle hooks
// (Init/Destroy) match steward's Initer/Destroyer interfaces by signature.
type Database struct {
Cfg config.DatabaseConfig `config:""`
DB *sqlx.DB
}
// Init opens the SQLite database with the locked pragmas (WAL, foreign keys
// on, NORMAL synchronous, busy timeout from config) and applies all embedded
// migrations. Errors are wrapped with culpa codes DB_OPEN / DB_MIGRATE so the
// HTTP layer can map them to a single status.
func (d *Database) Init(ctx context.Context) error {
dsn := buildDSN(d.Cfg.Path, d.Cfg.BusyTimeout)
db, err := sqlx.ConnectContext(ctx, "sqlite", dsn)
if err != nil {
return culpa.WithCode(culpa.Wrap(err, "open sqlite"), "DB_OPEN")
}
if err := Migrate(db); err != nil {
_ = db.Close()
return culpa.WithCode(culpa.Wrap(err, "apply migrations"), "DB_MIGRATE")
}
d.DB = db
return nil
}
// Destroy closes the underlying database. Idempotent: calling Destroy after
// a partially-failed Init (or twice in a row) is a no-op.
func (d *Database) Destroy(_ context.Context) error {
if d.DB == nil {
return nil
}
db := d.DB
d.DB = nil
if err := db.Close(); err != nil {
return culpa.WithCode(culpa.Wrap(err, "close sqlite"), "DB_CLOSE")
}
return nil
}
// Migrate applies every up migration embedded in FS to db. It is exported as
// a pure function so tests can drive it directly against an in-memory
// database without spinning up the steward graph. migrate.ErrNoChange means
// the database is already at the latest version and is treated as success.
func Migrate(db *sqlx.DB) error {
src, err := iofs.New(FS, "migrations")
if err != nil {
return culpa.Wrap(err, "build iofs source")
}
driver, err := migratesqlite.WithInstance(db.DB, &migratesqlite.Config{})
if err != nil {
return culpa.Wrap(err, "build sqlite migrate driver")
}
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
if err != nil {
return culpa.Wrap(err, "build migrator")
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return culpa.Wrap(err, "migrate up")
}
return nil
}
// InTx runs fn inside a single sqlx transaction, committing on success and
// rolling back on any returned error. The caller's error is preserved as-is
// so errors.Is/As work upstream; rollback failures are logged via slog.
func InTx(ctx context.Context, db *sqlx.DB, fn func(*sqlx.Tx) error) error {
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return culpa.WithCode(culpa.Wrap(err, "begin tx"), "DB_TX_BEGIN")
}
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
slog.ErrorContext(ctx, "rollback failed",
slog.String("rollback_err", rbErr.Error()),
slog.String("orig_err", err.Error()),
)
}
return err
}
if err := tx.Commit(); err != nil {
return culpa.WithCode(culpa.Wrap(err, "commit tx"), "DB_TX_COMMIT")
}
return nil
}
// buildDSN renders a modernc.org/sqlite-compatible DSN that pins the
// pragmas every connection in the pool must apply. We pass busy_timeout in
// milliseconds (the unit the SQLite pragma expects) derived from the
// configured time.Duration.
func buildDSN(path string, busyTimeout time.Duration) string {
return fmt.Sprintf(
"%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(%d)&_pragma=foreign_keys(on)&_pragma=synchronous(NORMAL)&_pragma=cache_size(-2000)",
path, busyTimeout.Milliseconds(),
)
}