// Package database owns the sqlite connection and the schema. The // schema is embedded in the binary, so a deployment is one file: there // is no migrations directory to ship alongside it and no version of it // that can be out of step with the code that expects it. package database import ( "context" "database/sql" "embed" "fmt" "log/slog" "os" "path/filepath" "time" "go.uber.org/fx" "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/logger" // modernc.org/sqlite is the pure-Go driver: no cgo, so the binary // links statically and the container needs no libc. _ "modernc.org/sqlite" ) // schemaFS carries the migrations into the binary. // //go:embed schema/*.sql var schemaFS embed.FS // dirPerm is the mode for the data directory: owner-only, because it // holds the database. const dirPerm = 0o700 // pragmas are applied to every connection. WAL is what makes concurrent // reads not block on a write; busy_timeout is what turns the remaining // contention into a short wait rather than an immediate SQLITE_BUSY; // foreign_keys is off by default in sqlite and has to be asked for. const pragmas = ` PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA synchronous = NORMAL; ` // Params defines dependencies for Database. type Params struct { fx.In Config *config.Config Logger *logger.Logger } // Database is the handle to the application's sqlite database. type Database struct { db *sql.DB log *slog.Logger } // New opens the database, applies the embedded migrations, and // registers a close hook. Migrations run during OnStart rather than // lazily on first use: a schema that cannot be applied is a failure to // start, and the process says so before it accepts a request. func New(lc fx.Lifecycle, params Params) (*Database, error) { d := &Database{log: params.Logger.Get()} err := os.MkdirAll(filepath.Dir(params.Config.DBPath), dirPerm) if err != nil { return nil, fmt.Errorf("creating data directory: %w", err) } // New runs during graph construction, which has no request or // lifecycle context of its own; the pragmas are a handful of // in-process statements against a file that was just created. db, err := Open(context.Background(), params.Config.DBPath) if err != nil { return nil, err } d.db = db lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { return d.Migrate(ctx) }, OnStop: func(_ context.Context) error { d.log.Info("closing database") closeErr := d.db.Close() if closeErr != nil { return fmt.Errorf("closing database: %w", closeErr) } return nil }, }) return d, nil } // Open opens a sqlite database at path and applies the connection // pragmas. Exported so tests can open a scratch database without the // fx graph. func Open(ctx context.Context, path string) (*sql.DB, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("opening database %s: %w", path, err) } // sqlite tolerates exactly one writer. Holding the pool to a // single connection makes that limit explicit here rather than // intermittent under load, and WAL keeps readers off the writer's // back anyway. db.SetMaxOpenConns(1) db.SetConnMaxLifetime(time.Hour) _, err = db.ExecContext(ctx, pragmas) if err != nil { _ = db.Close() return nil, fmt.Errorf("applying pragmas: %w", err) } return db, nil } // NewForTest opens a scratch database in dir and migrates it. Test // helper, exported so that a project seeded from this template can use // it from any package's tests. func NewForTest(ctx context.Context, dir string) (*Database, error) { d := &Database{log: slog.New(slog.DiscardHandler)} db, err := Open(ctx, filepath.Join(dir, "test.db")) if err != nil { return nil, err } d.db = db err = d.Migrate(ctx) if err != nil { _ = db.Close() return nil, err } return d, nil } // Migrate applies every embedded migration that has not been applied to // this database yet. func (d *Database) Migrate(ctx context.Context) error { set := migrationSet{fsys: schemaFS, dir: "schema"} err := set.apply(ctx, d.db, d.log) if err != nil { return fmt.Errorf("applying migrations: %w", err) } return nil } // DB exposes the underlying handle for packages that need to query it. func (d *Database) DB() *sql.DB { return d.db } // AppliedVersions returns the migration versions recorded as applied, // ascending. The healthcheck reports the highest of them, so an // operator can see which schema a running instance is on without // shelling into it. func (d *Database) AppliedVersions(ctx context.Context) ([]int, error) { rows, err := d.db.QueryContext(ctx, "SELECT version FROM schema_migrations ORDER BY version", ) if err != nil { return nil, fmt.Errorf("reading applied migrations: %w", err) } defer func() { _ = rows.Close() }() var versions []int for rows.Next() { var v int scanErr := rows.Scan(&v) if scanErr != nil { return nil, fmt.Errorf("scanning migration version: %w", scanErr) } versions = append(versions, v) } err = rows.Err() if err != nil { return nil, fmt.Errorf("iterating applied migrations: %w", err) } return versions, nil } // Close releases the handle. Production uses the fx OnStop hook; tests // call this. func (d *Database) Close() error { err := d.db.Close() if err != nil { return fmt.Errorf("closing database: %w", err) } return nil }