Files
simplexcalc/internal/database/database.go
T
clawbot f8ce8cef83 Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after
script/rename simplexcalc.

Model: opus-5-5
2026-09-26 21:38:57 +00:00

212 lines
5.3 KiB
Go

// 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
}