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
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user