~bigbes/lethe

ref: 7cffe38a672c0f5fc825235f496c72bebc9ee2b3 lethe/internal/platform/database/database.go -rw-r--r-- 3.8 KiB
7cffe38a — Eugene Blikh web: add display preference modules a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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(),
	)
}