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(), ) }