The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
212 lines
5.3 KiB
Go
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
|
|
}
|